Skip to main content

fd_core/
model.rs

1//! Core scene-graph data model for FD documents.
2//!
3//! The document is a DAG (Directed Acyclic Graph) where nodes represent
4//! visual elements (shapes, text, groups) and edges represent parent→child
5//! containment. Styles and animations are attached to nodes. Layout is
6//! constraint-based — relationships are preferred over raw positions.
7//! `Position { x, y }` is the escape hatch for drag-placed or pinned nodes.
8
9use crate::id::NodeId;
10use petgraph::graph::NodeIndex;
11use petgraph::stable_graph::StableDiGraph;
12use serde::{Deserialize, Serialize};
13use smallvec::SmallVec;
14use std::collections::HashMap;
15
16// ─── Colors & Paint ──────────────────────────────────────────────────────
17
18/// RGBA color. Stored as 4 × f32 [0.0, 1.0].
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct Color {
21    pub r: f32,
22    pub g: f32,
23    pub b: f32,
24    pub a: f32,
25}
26
27/// Compile-time lookup table for fast hex character parsing.
28/// Maps ASCII bytes to their 0-15 hex value, or 255 if invalid.
29/// This avoids branching (match statement) in the hot parsing path.
30const HEX_LUT: [u8; 256] = {
31    let mut lut = [255; 256];
32    let mut i = 0;
33    while i < 10 {
34        lut[(b'0' + i) as usize] = i;
35        i += 1;
36    }
37    let mut i = 0;
38    while i < 6 {
39        lut[(b'a' + i) as usize] = i + 10;
40        lut[(b'A' + i) as usize] = i + 10;
41        i += 1;
42    }
43    lut
44};
45
46/// Helper to parse a single hex digit.
47#[inline(always)]
48pub fn hex_val(c: u8) -> Option<u8> {
49    let val = HEX_LUT[c as usize];
50    if val != 255 { Some(val) } else { None }
51}
52
53impl Color {
54    /// Create a new color from RGBA components.
55    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
56        Self { r, g, b, a }
57    }
58
59    /// Parse a hex color string: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`.
60    /// The string may optionally start with `#`.
61    pub fn from_hex(hex: &str) -> Option<Self> {
62        let hex = hex.strip_prefix('#').unwrap_or(hex);
63        let bytes = hex.as_bytes();
64
65        match bytes.len() {
66            3 => {
67                let r = hex_val(bytes[0])?;
68                let g = hex_val(bytes[1])?;
69                let b = hex_val(bytes[2])?;
70                Some(Self::rgba(
71                    (r * 17) as f32 / 255.0,
72                    (g * 17) as f32 / 255.0,
73                    (b * 17) as f32 / 255.0,
74                    1.0,
75                ))
76            }
77            4 => {
78                let r = hex_val(bytes[0])?;
79                let g = hex_val(bytes[1])?;
80                let b = hex_val(bytes[2])?;
81                let a = hex_val(bytes[3])?;
82                Some(Self::rgba(
83                    (r * 17) as f32 / 255.0,
84                    (g * 17) as f32 / 255.0,
85                    (b * 17) as f32 / 255.0,
86                    (a * 17) as f32 / 255.0,
87                ))
88            }
89            6 => {
90                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
91                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
92                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
93                Some(Self::rgba(
94                    r as f32 / 255.0,
95                    g as f32 / 255.0,
96                    b as f32 / 255.0,
97                    1.0,
98                ))
99            }
100            8 => {
101                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
102                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
103                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
104                let a = hex_val(bytes[6])? << 4 | hex_val(bytes[7])?;
105                Some(Self::rgba(
106                    r as f32 / 255.0,
107                    g as f32 / 255.0,
108                    b as f32 / 255.0,
109                    a as f32 / 255.0,
110                ))
111            }
112            _ => None,
113        }
114    }
115
116    /// Emit as shortest valid hex string.
117    pub fn to_hex(&self) -> String {
118        let r = (self.r * 255.0).round() as u8;
119        let g = (self.g * 255.0).round() as u8;
120        let b = (self.b * 255.0).round() as u8;
121        let a = (self.a * 255.0).round() as u8;
122        if a == 255 {
123            format!("#{r:02X}{g:02X}{b:02X}")
124        } else {
125            format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
126        }
127    }
128}
129
130/// A gradient stop.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct GradientStop {
133    pub offset: f32, // 0.0 .. 1.0
134    pub color: Color,
135}
136
137/// Fill or stroke paint.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub enum Paint {
140    Solid(Color),
141    LinearGradient {
142        angle: f32, // degrees
143        stops: Vec<GradientStop>,
144    },
145    RadialGradient {
146        stops: Vec<GradientStop>,
147    },
148}
149
150// ─── Stroke ──────────────────────────────────────────────────────────────
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Stroke {
154    pub paint: Paint,
155    pub width: f32,
156    pub cap: StrokeCap,
157    pub join: StrokeJoin,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161pub enum StrokeCap {
162    Butt,
163    Round,
164    Square,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168pub enum StrokeJoin {
169    Miter,
170    Round,
171    Bevel,
172}
173
174impl Default for Stroke {
175    fn default() -> Self {
176        Self {
177            paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
178            width: 1.0,
179            cap: StrokeCap::Butt,
180            join: StrokeJoin::Miter,
181        }
182    }
183}
184
185// ─── Font / Text ─────────────────────────────────────────────────────────
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct FontSpec {
189    pub family: String,
190    pub weight: u16, // 100..900
191    pub size: f32,
192}
193
194impl Default for FontSpec {
195    fn default() -> Self {
196        Self {
197            family: "Inter".into(),
198            weight: 400,
199            size: 14.0,
200        }
201    }
202}
203
204// ─── Path data ───────────────────────────────────────────────────────────
205
206/// A single path command (SVG-like but simplified).
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub enum PathCmd {
209    MoveTo(f32, f32),
210    LineTo(f32, f32),
211    QuadTo(f32, f32, f32, f32),            // control, end
212    CubicTo(f32, f32, f32, f32, f32, f32), // c1, c2, end
213    Close,
214}
215
216// ─── Image data ──────────────────────────────────────────────────────────
217
218/// Source for an embedded image.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub enum ImageSource {
221    /// Relative file path: `src: "assets/hero.png"`.
222    File(String),
223}
224
225/// How an image fits within its declared dimensions.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
227pub enum ImageFit {
228    /// Scale to cover bounds, crop overflow.
229    #[default]
230    Cover,
231    /// Scale to fit within bounds, letterbox.
232    Contain,
233    /// Stretch to exact dimensions.
234    Fill,
235    /// Natural size, no scaling.
236    None,
237}
238
239// ─── Shadow ──────────────────────────────────────────────────────────────
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct Shadow {
243    pub offset_x: f32,
244    pub offset_y: f32,
245    pub blur: f32,
246    pub color: Color,
247}
248
249// ─── Styling ─────────────────────────────────────────────────────────────
250
251/// Horizontal text alignment.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
253pub enum TextAlign {
254    Left,
255    #[default]
256    Center,
257    Right,
258}
259
260/// Vertical text alignment.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
262pub enum TextVAlign {
263    Top,
264    #[default]
265    Middle,
266    Bottom,
267}
268
269/// Horizontal placement of a child within its parent.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
271pub enum HPlace {
272    Left,
273    #[default]
274    Center,
275    Right,
276}
277
278/// Vertical placement of a child within its parent.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280pub enum VPlace {
281    Top,
282    #[default]
283    Middle,
284    Bottom,
285}
286
287/// A reusable style set that nodes can reference via `use: style_name`.
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct Properties {
290    pub fill: Option<Paint>,
291    pub stroke: Option<Stroke>,
292    pub font: Option<FontSpec>,
293    pub corner_radius: Option<f32>,
294    pub opacity: Option<f32>,
295    pub shadow: Option<Shadow>,
296
297    /// Horizontal text alignment (default: Center).
298    pub text_align: Option<TextAlign>,
299    /// Vertical text alignment (default: Middle).
300    pub text_valign: Option<TextVAlign>,
301
302    /// Scale factor applied during rendering (from animations).
303    pub scale: Option<f32>,
304}
305
306// ─── Animation ───────────────────────────────────────────────────────────
307
308/// The trigger for an animation.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub enum AnimTrigger {
311    Hover,
312    Press,
313    Enter, // viewport enter
314    Custom(String),
315}
316
317/// Easing function.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub enum Easing {
320    Linear,
321    EaseIn,
322    EaseOut,
323    EaseInOut,
324    Spring,
325    CubicBezier(f32, f32, f32, f32),
326}
327
328/// A property animation keyframe.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct AnimKeyframe {
331    pub trigger: AnimTrigger,
332    pub duration_ms: u32,
333    pub easing: Easing,
334    pub properties: AnimProperties,
335    /// Optional post-revert cooldown (ms) before re-triggerable.
336    /// `None` = no cooldown (default).
337    pub delay_ms: Option<u32>,
338}
339
340/// Animatable property overrides.
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct AnimProperties {
343    pub fill: Option<Paint>,
344    pub opacity: Option<f32>,
345    pub scale: Option<f32>,
346    pub rotate: Option<f32>, // degrees
347    pub translate: Option<(f32, f32)>,
348}
349
350// ─── Imports ─────────────────────────────────────────────────────────────
351
352/// A file import declaration: `import "path.fd" as namespace`.
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct Import {
355    /// Relative file path, e.g. "components/buttons.fd".
356    pub path: String,
357    /// Namespace alias, e.g. "buttons".
358    pub namespace: String,
359}
360
361// ─── Layout Constraints ──────────────────────────────────────────────────
362
363/// Constraint-based layout — no absolute coordinates in the format.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub enum Constraint {
366    /// Center this node within a target (e.g. `canvas` or another node).
367    CenterIn(NodeId),
368    /// Position relative: dx, dy from a reference node.
369    Offset { from: NodeId, dx: f32, dy: f32 },
370    /// Fill the parent with optional padding.
371    FillParent { pad: f32 },
372    /// Parent-relative position (used for drag-placed or pinned nodes).
373    /// Resolved as `parent.x + x`, `parent.y + y` by the layout solver.
374    Position { x: f32, y: f32 },
375}
376
377// ─── Edges (connections between nodes) ───────────────────────────────────
378
379/// Arrow head placement on an edge.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
381pub enum ArrowKind {
382    #[default]
383    None,
384    Start,
385    End,
386    Both,
387}
388
389/// How the edge path is drawn between two nodes.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
391pub enum CurveKind {
392    #[default]
393    Straight,
394    Smooth,
395    Step,
396}
397
398/// An edge endpoint — either connected to a node or a free point in scene-space.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub enum EdgeAnchor {
401    /// Connected to a node center.
402    Node(NodeId),
403    /// Fixed position in scene-space (standalone arrow).
404    Point(f32, f32),
405}
406
407impl EdgeAnchor {
408    /// Return the NodeId if this is a Node anchor.
409    pub fn node_id(&self) -> Option<NodeId> {
410        match self {
411            Self::Node(id) => Some(*id),
412            Self::Point(_, _) => None,
413        }
414    }
415}
416
417/// Document-level default styles for edges.
418///
419/// When an `edge_defaults` block is present, individual edges omit
420/// properties that match the defaults — saving tokens for documents
421/// with many similarly styled edges.
422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct EdgeDefaults {
424    pub props: Properties,
425    pub arrow: Option<ArrowKind>,
426    pub curve: Option<CurveKind>,
427}
428
429/// A visual connection between two endpoints.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct Edge {
432    pub id: NodeId,
433    pub from: EdgeAnchor,
434    pub to: EdgeAnchor,
435    /// Optional text child node (max 1). The node lives in the SceneGraph.
436    pub text_child: Option<NodeId>,
437    pub props: Properties,
438    pub use_styles: SmallVec<[NodeId; 2]>,
439    pub arrow: ArrowKind,
440    pub curve: CurveKind,
441    pub note: Option<String>,
442    pub animations: SmallVec<[AnimKeyframe; 2]>,
443    pub flow: Option<FlowAnim>,
444    /// Offset of the edge text from the midpoint, set when label is dragged.
445    pub label_offset: Option<(f32, f32)>,
446}
447
448/// Flow animation kind — continuous motion along the edge path.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450pub enum FlowKind {
451    /// A glowing dot traveling from → to on a loop.
452    Pulse,
453    /// Marching dashes along the edge (stroke-dashoffset animation).
454    Dash,
455}
456
457/// A flow animation attached to an edge.
458#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
459pub struct FlowAnim {
460    pub kind: FlowKind,
461    pub duration_ms: u32,
462}
463
464/// Group layout mode (for children arrangement).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub enum LayoutMode {
467    /// Free / absolute positioning of children.
468    /// Optional padding insets the content area (default 0).
469    Free { pad: f32 },
470    /// Column (vertical stack).
471    Column { gap: f32, pad: f32 },
472    /// Row (horizontal stack).
473    Row { gap: f32, pad: f32 },
474    /// Grid layout.
475    Grid { cols: u32, gap: f32, pad: f32 },
476}
477
478impl Default for LayoutMode {
479    fn default() -> Self {
480        LayoutMode::Free { pad: 0.0 }
481    }
482}
483
484// ─── Scene Graph Nodes ───────────────────────────────────────────────────
485
486/// The node kinds in the scene DAG.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub enum NodeKind {
489    /// Root of the document.
490    Root,
491
492    /// Generic placeholder — no visual shape assigned yet.
493    /// Used for note-only nodes: `@login_btn { note "CTA" }`
494    Generic,
495
496    /// Organizational container (like Figma Group).
497    /// Auto-sizes to children, no own styles or layout modes.
498    Group,
499
500    /// Frame — visible container with explicit size and optional clipping.
501    /// Like a Figma frame: has fill/stroke, declared dimensions, clips overflow.
502    Frame {
503        width: f32,
504        height: f32,
505        clip: bool,
506        layout: LayoutMode,
507    },
508
509    /// Rectangle.
510    Rect { width: f32, height: f32 },
511
512    /// Ellipse / circle.
513    Ellipse { rx: f32, ry: f32 },
514
515    /// Freeform path (pen tool output).
516    Path { commands: Vec<PathCmd> },
517
518    /// Embedded image (R3.32).
519    Image {
520        source: ImageSource,
521        width: f32,
522        height: f32,
523        fit: ImageFit,
524    },
525
526    /// Text label. Optional `max_width` constrains horizontal extent
527    /// for word wrapping (set via resize handle drag).
528    Text {
529        content: String,
530        max_width: Option<f32>,
531    },
532}
533
534impl NodeKind {
535    /// Return the FD format keyword for this node kind.
536    pub fn kind_name(&self) -> &'static str {
537        match self {
538            Self::Root => "root",
539            Self::Generic => "generic",
540            Self::Group => "group",
541            Self::Frame { .. } => "frame",
542            Self::Rect { .. } => "rect",
543            Self::Ellipse { .. } => "ellipse",
544            Self::Path { .. } => "path",
545            Self::Image { .. } => "image",
546            Self::Text { .. } => "text",
547        }
548    }
549}
550
551/// A single node in the scene graph.
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct SceneNode {
554    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
555    pub id: NodeId,
556
557    /// What kind of element this is.
558    pub kind: NodeKind,
559
560    /// Inline style overrides on this node.
561    pub props: Properties,
562
563    /// Named style references (`use: base_text`).
564    pub use_styles: SmallVec<[NodeId; 2]>,
565
566    /// Constraint-based positioning.
567    pub constraints: SmallVec<[Constraint; 2]>,
568
569    /// Animations attached to this node.
570    pub animations: SmallVec<[AnimKeyframe; 2]>,
571
572    /// Markdown note content (`note { ... }` block, also accepts legacy `spec`).
573    pub note: Option<String>,
574
575    /// Line comments (`# text`) that appeared before this node in the source.
576    /// Preserved across parse/emit round-trips so format passes don't delete them.
577    pub comments: Vec<String>,
578
579    /// 9-position placement of this child within its parent.
580    /// `None` = default positioning (auto-center for text, origin for others).
581    pub place: Option<(HPlace, VPlace)>,
582
583    /// Whether this node is locked (prevents move, resize, delete on canvas).
584    /// Parsed from `locked: true` in the FD format.
585    pub locked: bool,
586}
587
588impl SceneNode {
589    /// Create a new SceneNode with a given ID and kind.
590    pub fn new(id: NodeId, kind: NodeKind) -> Self {
591        Self {
592            id,
593            kind,
594            props: Properties::default(),
595            use_styles: SmallVec::new(),
596            constraints: SmallVec::new(),
597            animations: SmallVec::new(),
598            note: None,
599            comments: Vec::new(),
600            place: None,
601            locked: false,
602        }
603    }
604}
605
606// ─── Graph Snapshot (for ReadMode::Diff) ─────────────────────────────────
607
608/// A lightweight snapshot of graph state for diff computation.
609///
610/// Stores per-node and per-edge hashes so that changes can be detected
611/// by comparing hashes rather than storing full copies of the graph.
612#[derive(Debug, Clone, Default)]
613pub struct GraphSnapshot {
614    /// Hash of each node's emitted text, keyed by NodeId.
615    pub node_hashes: HashMap<NodeId, u64>,
616    /// Hash of each edge's emitted text, keyed by edge id.
617    pub edge_hashes: HashMap<NodeId, u64>,
618}
619
620// ─── Scene Graph ─────────────────────────────────────────────────────────
621
622/// The complete FD document — a DAG of `SceneNode` values.
623///
624/// Edges go from parent → child. Style definitions are stored separately
625/// in a hashmap for lookup by name.
626#[derive(Debug, Clone)]
627pub struct SceneGraph {
628    /// The underlying directed graph.
629    pub graph: StableDiGraph<SceneNode, ()>,
630
631    /// The root node index.
632    pub root: NodeIndex,
633
634    /// Named style definitions (`style base_text { ... }`).
635    pub styles: HashMap<NodeId, Properties>,
636
637    /// Index from NodeId → NodeIndex for fast lookup.
638    pub id_index: HashMap<NodeId, NodeIndex>,
639
640    /// Visual edges (connections between nodes).
641    pub edges: Vec<Edge>,
642
643    /// File imports with namespace aliases.
644    pub imports: Vec<Import>,
645
646    /// Explicit child ordering set by `sort_nodes`.
647    /// When present for a parent, `children()` returns this order
648    /// instead of the default `NodeIndex` sort.
649    pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
650
651    /// Document-level default styles for edges.
652    /// When present, individual edge properties matching the defaults are omitted.
653    pub edge_defaults: Option<EdgeDefaults>,
654}
655
656impl SceneGraph {
657    /// Create a new empty scene graph with a root node.
658    #[must_use]
659    pub fn new() -> Self {
660        let mut graph = StableDiGraph::new();
661        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
662        let root = graph.add_node(root_node);
663
664        let mut id_index = HashMap::new();
665        id_index.insert(NodeId::intern("root"), root);
666
667        Self {
668            graph,
669            root,
670            styles: HashMap::new(),
671            id_index,
672            edges: Vec::new(),
673            imports: Vec::new(),
674            sorted_child_order: HashMap::new(),
675            edge_defaults: None,
676        }
677    }
678
679    /// Add a node as a child of `parent`. Returns the new node's index.
680    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
681        let id = node.id;
682        let idx = self.graph.add_node(node);
683        self.graph.add_edge(parent, idx, ());
684        self.id_index.insert(id, idx);
685        idx
686    }
687
688    /// Remove a node safely, keeping the `id_index` synchronized.
689    pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
690        let removed = self.graph.remove_node(idx);
691        if let Some(removed_node) = &removed {
692            self.id_index.remove(&removed_node.id);
693        }
694        removed
695    }
696
697    /// Look up a node by its `@id`.
698    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
699        self.id_index.get(&id).map(|idx| &self.graph[*idx])
700    }
701
702    /// Look up a node mutably by its `@id`.
703    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
704        self.id_index
705            .get(&id)
706            .copied()
707            .map(|idx| &mut self.graph[idx])
708    }
709
710    /// Get the index for a NodeId.
711    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
712        self.id_index.get(&id).copied()
713    }
714
715    /// Get the parent index of a node.
716    pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
717        self.graph
718            .neighbors_directed(idx, petgraph::Direction::Incoming)
719            .next()
720    }
721
722    /// Reparent a node to a new parent.
723    pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
724        if let Some(old_parent) = self.parent(child)
725            && let Some(edge) = self.graph.find_edge(old_parent, child)
726        {
727            self.graph.remove_edge(edge);
728        }
729        self.graph.add_edge(new_parent, child, ());
730    }
731
732    /// Get children of a node in document (insertion) order.
733    ///
734    /// Sorts by `NodeIndex` so the result is deterministic regardless of
735    /// how `petgraph` iterates its adjacency list on different targets
736    /// (native vs WASM).
737    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
738        // If an explicit sort order was set (by sort_nodes), use it
739        if let Some(order) = self.sorted_child_order.get(&idx) {
740            return order.clone();
741        }
742
743        let mut children: Vec<NodeIndex> = self
744            .graph
745            .neighbors_directed(idx, petgraph::Direction::Outgoing)
746            .collect();
747        children.sort();
748        children
749    }
750
751    /// Move a child one step backward in z-order (swap with previous sibling).
752    /// Returns true if the z-order changed.
753    pub fn send_backward(&mut self, child: NodeIndex) -> bool {
754        let parent = match self.parent(child) {
755            Some(p) => p,
756            None => return false,
757        };
758        let siblings = self.children(parent);
759        let pos = match siblings.iter().position(|&s| s == child) {
760            Some(p) => p,
761            None => return false,
762        };
763        if pos == 0 {
764            return false; // already at back
765        }
766        // Rebuild edges in swapped order
767        self.rebuild_child_order(parent, &siblings, pos, pos - 1)
768    }
769
770    /// Move a child one step forward in z-order (swap with next sibling).
771    /// Returns true if the z-order changed.
772    pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
773        let parent = match self.parent(child) {
774            Some(p) => p,
775            None => return false,
776        };
777        let siblings = self.children(parent);
778        let pos = match siblings.iter().position(|&s| s == child) {
779            Some(p) => p,
780            None => return false,
781        };
782        if pos >= siblings.len() - 1 {
783            return false; // already at front
784        }
785        self.rebuild_child_order(parent, &siblings, pos, pos + 1)
786    }
787
788    /// Move a child to the back of z-order (first child).
789    pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
790        let parent = match self.parent(child) {
791            Some(p) => p,
792            None => return false,
793        };
794        let siblings = self.children(parent);
795        let pos = match siblings.iter().position(|&s| s == child) {
796            Some(p) => p,
797            None => return false,
798        };
799        if pos == 0 {
800            return false;
801        }
802        self.rebuild_child_order(parent, &siblings, pos, 0)
803    }
804
805    /// Move a child to the front of z-order (last child).
806    pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
807        let parent = match self.parent(child) {
808            Some(p) => p,
809            None => return false,
810        };
811        let siblings = self.children(parent);
812        let pos = match siblings.iter().position(|&s| s == child) {
813            Some(p) => p,
814            None => return false,
815        };
816        let last = siblings.len() - 1;
817        if pos == last {
818            return false;
819        }
820        self.rebuild_child_order(parent, &siblings, pos, last)
821    }
822
823    /// Rebuild child edges, moving child at `from` to `to` position.
824    fn rebuild_child_order(
825        &mut self,
826        parent: NodeIndex,
827        siblings: &[NodeIndex],
828        from: usize,
829        to: usize,
830    ) -> bool {
831        // Remove all edges from parent to children
832        for &sib in siblings {
833            if let Some(edge) = self.graph.find_edge(parent, sib) {
834                self.graph.remove_edge(edge);
835            }
836        }
837        // Build new order
838        let mut new_order: Vec<NodeIndex> = siblings.to_vec();
839        let child = new_order.remove(from);
840        new_order.insert(to, child);
841        // Re-add edges in new order
842        for &sib in &new_order {
843            self.graph.add_edge(parent, sib, ());
844        }
845        // Store explicit child order so children() returns z-order, not NodeIndex order
846        self.sorted_child_order.insert(parent, new_order);
847        true
848    }
849
850    /// Define a named style.
851    pub fn define_style(&mut self, name: NodeId, style: Properties) {
852        self.styles.insert(name, style);
853    }
854
855    /// Resolve a node's effective style (merging `use` references + inline overrides + active animations).
856    pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Properties {
857        let mut resolved = Properties::default();
858
859        // Apply referenced styles in order
860        for style_id in &node.use_styles {
861            if let Some(base) = self.styles.get(style_id) {
862                merge_style(&mut resolved, base);
863            }
864        }
865
866        // Apply inline overrides (take precedence)
867        merge_style(&mut resolved, &node.props);
868
869        // Apply active animation state overrides
870        for anim in &node.animations {
871            if active_triggers.contains(&anim.trigger) {
872                if anim.properties.fill.is_some() {
873                    resolved.fill = anim.properties.fill.clone();
874                }
875                if anim.properties.opacity.is_some() {
876                    resolved.opacity = anim.properties.opacity;
877                }
878                if anim.properties.scale.is_some() {
879                    resolved.scale = anim.properties.scale;
880                }
881            }
882        }
883
884        resolved
885    }
886
887    /// Rebuild the `id_index` (needed after deserialization).
888    pub fn rebuild_index(&mut self) {
889        self.id_index.clear();
890        for idx in self.graph.node_indices() {
891            let id = self.graph[idx].id;
892            self.id_index.insert(id, idx);
893        }
894    }
895
896    /// Resolve an edge's effective style (merging `use` references + inline overrides + active animations).
897    pub fn resolve_style_for_edge(
898        &self,
899        edge: &Edge,
900        active_triggers: &[AnimTrigger],
901    ) -> Properties {
902        let mut resolved = Properties::default();
903        for style_id in &edge.use_styles {
904            if let Some(base) = self.styles.get(style_id) {
905                merge_style(&mut resolved, base);
906            }
907        }
908        merge_style(&mut resolved, &edge.props);
909
910        for anim in &edge.animations {
911            if active_triggers.contains(&anim.trigger) {
912                if anim.properties.fill.is_some() {
913                    resolved.fill = anim.properties.fill.clone();
914                }
915                if anim.properties.opacity.is_some() {
916                    resolved.opacity = anim.properties.opacity;
917                }
918                if anim.properties.scale.is_some() {
919                    resolved.scale = anim.properties.scale;
920                }
921            }
922        }
923
924        resolved
925    }
926
927    /// Resolve the effective click target for a leaf node.
928    ///
929    /// Figma-style group selection with progressive drill-down:
930    /// - **First click** → selects the topmost group ancestor (below root).
931    /// - **Click again** (topmost group already selected) → next-level group.
932    /// - **Click again** (all group ancestors selected) → the leaf itself.
933    pub fn effective_target(&self, leaf_id: NodeId, selected: &[NodeId]) -> NodeId {
934        let leaf_idx = match self.index_of(leaf_id) {
935            Some(idx) => idx,
936            None => return leaf_id,
937        };
938
939        // Walk up from the leaf, collecting group ancestors below root
940        // in bottom-up order.
941        let mut groups_bottom_up: Vec<NodeId> = Vec::new();
942        let mut cursor = self.parent(leaf_idx);
943        while let Some(parent_idx) = cursor {
944            if parent_idx == self.root {
945                break;
946            }
947            if matches!(self.graph[parent_idx].kind, NodeKind::Group) {
948                groups_bottom_up.push(self.graph[parent_idx].id);
949            }
950            cursor = self.parent(parent_idx);
951        }
952
953        // Reverse to get top-down order (topmost group first).
954        groups_bottom_up.reverse();
955
956        // Find the deepest selected group in the ancestor chain.
957        // If a selected node is in the chain, advance to the next level down.
958        let deepest_selected_pos = groups_bottom_up
959            .iter()
960            .rposition(|gid| selected.contains(gid));
961
962        match deepest_selected_pos {
963            None => {
964                // Nothing in the chain is selected → return topmost group
965                if let Some(top) = groups_bottom_up.first() {
966                    return *top;
967                }
968            }
969            Some(pos) if pos + 1 < groups_bottom_up.len() => {
970                // Selected group is not the deepest → advance one level
971                return groups_bottom_up[pos + 1];
972            }
973            Some(_) => {
974                // Deepest group is already selected → drill to leaf
975            }
976        }
977
978        leaf_id
979    }
980
981    /// Check if `ancestor_id` is a parent/grandparent/etc. of `descendant_id`.
982    pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
983        if ancestor_id == descendant_id {
984            return false;
985        }
986        let mut current_idx = match self.index_of(descendant_id) {
987            Some(idx) => idx,
988            None => return false,
989        };
990        while let Some(parent_idx) = self.parent(current_idx) {
991            if self.graph[parent_idx].id == ancestor_id {
992                return true;
993            }
994            if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
995                break;
996            }
997            current_idx = parent_idx;
998        }
999        false
1000    }
1001}
1002
1003impl Default for SceneGraph {
1004    fn default() -> Self {
1005        Self::new()
1006    }
1007}
1008
1009/// Merge `src` style into `dst`, overwriting only `Some` fields.
1010fn merge_style(dst: &mut Properties, src: &Properties) {
1011    if src.fill.is_some() {
1012        dst.fill = src.fill.clone();
1013    }
1014    if src.stroke.is_some() {
1015        dst.stroke = src.stroke.clone();
1016    }
1017    if src.font.is_some() {
1018        dst.font = src.font.clone();
1019    }
1020    if src.corner_radius.is_some() {
1021        dst.corner_radius = src.corner_radius;
1022    }
1023    if src.opacity.is_some() {
1024        dst.opacity = src.opacity;
1025    }
1026    if src.shadow.is_some() {
1027        dst.shadow = src.shadow.clone();
1028    }
1029
1030    if src.text_align.is_some() {
1031        dst.text_align = src.text_align;
1032    }
1033    if src.text_valign.is_some() {
1034        dst.text_valign = src.text_valign;
1035    }
1036    if src.scale.is_some() {
1037        dst.scale = src.scale;
1038    }
1039}
1040
1041// ─── Resolved positions (output of layout solver) ────────────────────────
1042
1043/// Resolved absolute bounding box after constraint solving.
1044#[derive(Debug, Clone, Copy, Default, PartialEq)]
1045pub struct ResolvedBounds {
1046    pub x: f32,
1047    pub y: f32,
1048    pub width: f32,
1049    pub height: f32,
1050}
1051
1052impl ResolvedBounds {
1053    /// Check if a point (px, py) is inside these bounds.
1054    pub fn contains(&self, px: f32, py: f32) -> bool {
1055        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
1056    }
1057
1058    /// Return the center point of these bounds.
1059    pub fn center(&self) -> (f32, f32) {
1060        (self.x + self.width / 2.0, self.y + self.height / 2.0)
1061    }
1062
1063    /// Check if this bounds intersects with a rectangle (AABB overlap).
1064    pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
1065        self.x < rx + rw
1066            && self.x + self.width > rx
1067            && self.y < ry + rh
1068            && self.y + self.height > ry
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    #[test]
1077    fn scene_graph_basics() {
1078        let mut sg = SceneGraph::new();
1079        let rect = SceneNode::new(
1080            NodeId::intern("box1"),
1081            NodeKind::Rect {
1082                width: 100.0,
1083                height: 50.0,
1084            },
1085        );
1086        let idx = sg.add_node(sg.root, rect);
1087
1088        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
1089        assert_eq!(sg.children(sg.root).len(), 1);
1090        assert_eq!(sg.children(sg.root)[0], idx);
1091    }
1092
1093    #[test]
1094    fn color_hex_roundtrip() {
1095        let c = Color::from_hex("#6C5CE7").unwrap();
1096        assert_eq!(c.to_hex(), "#6C5CE7");
1097
1098        let c2 = Color::from_hex("#FF000080").unwrap();
1099        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
1100        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
1101    }
1102
1103    #[test]
1104    fn style_merging() {
1105        let mut sg = SceneGraph::new();
1106        sg.define_style(
1107            NodeId::intern("base"),
1108            Properties {
1109                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
1110                font: Some(FontSpec {
1111                    family: "Inter".into(),
1112                    weight: 400,
1113                    size: 14.0,
1114                }),
1115                ..Default::default()
1116            },
1117        );
1118
1119        let mut node = SceneNode::new(
1120            NodeId::intern("txt"),
1121            NodeKind::Text {
1122                content: "hi".into(),
1123                max_width: None,
1124            },
1125        );
1126        node.use_styles.push(NodeId::intern("base"));
1127        node.props.font = Some(FontSpec {
1128            family: "Inter".into(),
1129            weight: 700,
1130            size: 24.0,
1131        });
1132
1133        let resolved = sg.resolve_style(&node, &[]);
1134        // Fill comes from base style
1135        assert!(resolved.fill.is_some());
1136        // Font comes from inline override
1137        let f = resolved.font.unwrap();
1138        assert_eq!(f.weight, 700);
1139        assert_eq!(f.size, 24.0);
1140    }
1141
1142    #[test]
1143    fn style_merging_align() {
1144        let mut sg = SceneGraph::new();
1145        sg.define_style(
1146            NodeId::intern("centered"),
1147            Properties {
1148                text_align: Some(TextAlign::Center),
1149                text_valign: Some(TextVAlign::Middle),
1150                ..Default::default()
1151            },
1152        );
1153
1154        // Node with use: centered + inline override of text_align to Right
1155        let mut node = SceneNode::new(
1156            NodeId::intern("overridden"),
1157            NodeKind::Text {
1158                content: "hello".into(),
1159                max_width: None,
1160            },
1161        );
1162        node.use_styles.push(NodeId::intern("centered"));
1163        node.props.text_align = Some(TextAlign::Right);
1164
1165        let resolved = sg.resolve_style(&node, &[]);
1166        // Horizontal should be overridden to Right
1167        assert_eq!(resolved.text_align, Some(TextAlign::Right));
1168        // Vertical should come from base style (Middle)
1169        assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
1170    }
1171
1172    #[test]
1173    fn test_effective_target_group_selects_group_first() {
1174        let mut sg = SceneGraph::new();
1175
1176        // Root -> Group -> Rect
1177        let group_id = NodeId::intern("my_group");
1178        let rect_id = NodeId::intern("my_rect");
1179
1180        let group = SceneNode::new(group_id, NodeKind::Group);
1181        let rect = SceneNode::new(
1182            rect_id,
1183            NodeKind::Rect {
1184                width: 10.0,
1185                height: 10.0,
1186            },
1187        );
1188
1189        let group_idx = sg.add_node(sg.root, group);
1190        sg.add_node(group_idx, rect);
1191
1192        // Single click (nothing selected): should select the group
1193        assert_eq!(sg.effective_target(rect_id, &[]), group_id);
1194        // Double click (group already selected): drill down to leaf
1195        assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
1196        // Group itself → returns group (it IS the leaf in this call)
1197        assert_eq!(sg.effective_target(group_id, &[]), group_id);
1198    }
1199
1200    #[test]
1201    fn test_effective_target_nested_groups_selects_topmost() {
1202        let mut sg = SceneGraph::new();
1203
1204        // Root -> group_outer -> group_inner -> rect_leaf
1205        let outer_id = NodeId::intern("group_outer");
1206        let inner_id = NodeId::intern("group_inner");
1207        let leaf_id = NodeId::intern("rect_leaf");
1208
1209        let outer = SceneNode::new(outer_id, NodeKind::Group);
1210        let inner = SceneNode::new(inner_id, NodeKind::Group);
1211        let leaf = SceneNode::new(
1212            leaf_id,
1213            NodeKind::Rect {
1214                width: 50.0,
1215                height: 50.0,
1216            },
1217        );
1218
1219        let outer_idx = sg.add_node(sg.root, outer);
1220        let inner_idx = sg.add_node(outer_idx, inner);
1221        sg.add_node(inner_idx, leaf);
1222
1223        // Single click (nothing selected): topmost group
1224        assert_eq!(sg.effective_target(leaf_id, &[]), outer_id);
1225        // Outer selected → drill to inner group
1226        assert_eq!(sg.effective_target(leaf_id, &[outer_id]), inner_id);
1227        // Both outer+inner selected → drill to leaf
1228        assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
1229        // Non-cumulative: only inner selected (SelectTool replaces, not accumulates)
1230        // Must drill to leaf — NOT loop back to outer
1231        assert_eq!(sg.effective_target(leaf_id, &[inner_id]), leaf_id);
1232    }
1233
1234    #[test]
1235    fn test_effective_target_nested_drill_down_three_levels() {
1236        let mut sg = SceneGraph::new();
1237
1238        // Root -> group_a -> group_b -> group_c -> rect_leaf
1239        let a_id = NodeId::intern("group_a");
1240        let b_id = NodeId::intern("group_b");
1241        let c_id = NodeId::intern("group_c");
1242        let leaf_id = NodeId::intern("deep_leaf");
1243
1244        let a = SceneNode::new(a_id, NodeKind::Group);
1245        let b = SceneNode::new(b_id, NodeKind::Group);
1246        let c = SceneNode::new(c_id, NodeKind::Group);
1247        let leaf = SceneNode::new(
1248            leaf_id,
1249            NodeKind::Rect {
1250                width: 10.0,
1251                height: 10.0,
1252            },
1253        );
1254
1255        let a_idx = sg.add_node(sg.root, a);
1256        let b_idx = sg.add_node(a_idx, b);
1257        let c_idx = sg.add_node(b_idx, c);
1258        sg.add_node(c_idx, leaf);
1259
1260        // Progressive drill-down (non-cumulative — SelectTool replaces selection)
1261        assert_eq!(sg.effective_target(leaf_id, &[]), a_id);
1262        assert_eq!(sg.effective_target(leaf_id, &[a_id]), b_id);
1263        assert_eq!(sg.effective_target(leaf_id, &[b_id]), c_id);
1264        assert_eq!(sg.effective_target(leaf_id, &[c_id]), leaf_id);
1265    }
1266
1267    #[test]
1268    fn test_visual_highlight_differs_from_selected() {
1269        // Visual highlight contract: when effective_target returns a group,
1270        // the UI should highlight the raw hit (leaf) not the group.
1271        let mut sg = SceneGraph::new();
1272
1273        let group_id = NodeId::intern("card");
1274        let child_id = NodeId::intern("card_title");
1275
1276        let group = SceneNode::new(group_id, NodeKind::Group);
1277        let child = SceneNode::new(
1278            child_id,
1279            NodeKind::Text {
1280                content: "Title".into(),
1281                max_width: None,
1282            },
1283        );
1284
1285        let group_idx = sg.add_node(sg.root, group);
1286        sg.add_node(group_idx, child);
1287
1288        // Raw hit = child_id, nothing selected
1289        let logical_target = sg.effective_target(child_id, &[]);
1290        // Logical selection should be the group
1291        assert_eq!(logical_target, group_id);
1292        // Visual highlight should be the child (raw hit != logical_target)
1293        assert_ne!(child_id, logical_target);
1294        // After drilling (group selected), both converge
1295        let drilled = sg.effective_target(child_id, &[group_id]);
1296        assert_eq!(drilled, child_id);
1297    }
1298
1299    #[test]
1300    fn test_effective_target_no_group() {
1301        let mut sg = SceneGraph::new();
1302
1303        // Root -> Rect (no group)
1304        let rect_id = NodeId::intern("standalone_rect");
1305        let rect = SceneNode::new(
1306            rect_id,
1307            NodeKind::Rect {
1308                width: 10.0,
1309                height: 10.0,
1310            },
1311        );
1312        sg.add_node(sg.root, rect);
1313
1314        // No group parent → returns leaf directly
1315        assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
1316    }
1317
1318    #[test]
1319    fn test_is_ancestor_of() {
1320        let mut sg = SceneGraph::new();
1321
1322        // Root -> Group -> Rect
1323        let group_id = NodeId::intern("grp");
1324        let rect_id = NodeId::intern("r1");
1325        let other_id = NodeId::intern("other");
1326
1327        let group = SceneNode::new(group_id, NodeKind::Group);
1328        let rect = SceneNode::new(
1329            rect_id,
1330            NodeKind::Rect {
1331                width: 10.0,
1332                height: 10.0,
1333            },
1334        );
1335        let other = SceneNode::new(
1336            other_id,
1337            NodeKind::Rect {
1338                width: 5.0,
1339                height: 5.0,
1340            },
1341        );
1342
1343        let group_idx = sg.add_node(sg.root, group);
1344        sg.add_node(group_idx, rect);
1345        sg.add_node(sg.root, other);
1346
1347        // Group is ancestor of rect
1348        assert!(sg.is_ancestor_of(group_id, rect_id));
1349        // Root is ancestor of rect (grandparent)
1350        assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
1351        // Rect is NOT ancestor of group
1352        assert!(!sg.is_ancestor_of(rect_id, group_id));
1353        // Self is NOT ancestor of self
1354        assert!(!sg.is_ancestor_of(group_id, group_id));
1355        // Other is not ancestor of rect (sibling)
1356        assert!(!sg.is_ancestor_of(other_id, rect_id));
1357    }
1358
1359    #[test]
1360    fn test_resolve_style_scale_animation() {
1361        let sg = SceneGraph::new();
1362
1363        let mut node = SceneNode::new(
1364            NodeId::intern("btn"),
1365            NodeKind::Rect {
1366                width: 100.0,
1367                height: 40.0,
1368            },
1369        );
1370        node.props.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
1371        node.animations.push(AnimKeyframe {
1372            trigger: AnimTrigger::Press,
1373            duration_ms: 100,
1374            easing: Easing::EaseOut,
1375            properties: AnimProperties {
1376                scale: Some(0.97),
1377                ..Default::default()
1378            },
1379            delay_ms: None,
1380        });
1381
1382        // Without press trigger: scale should be None
1383        let resolved = sg.resolve_style(&node, &[]);
1384        assert!(resolved.scale.is_none());
1385
1386        // With press trigger: scale should be 0.97
1387        let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
1388        assert_eq!(resolved.scale, Some(0.97));
1389        // Fill should still be present
1390        assert!(resolved.fill.is_some());
1391    }
1392
1393    #[test]
1394    fn z_order_bring_forward() {
1395        let mut sg = SceneGraph::new();
1396        let a = sg.add_node(
1397            sg.root,
1398            SceneNode::new(
1399                NodeId::intern("a"),
1400                NodeKind::Rect {
1401                    width: 50.0,
1402                    height: 50.0,
1403                },
1404            ),
1405        );
1406        let _b = sg.add_node(
1407            sg.root,
1408            SceneNode::new(
1409                NodeId::intern("b"),
1410                NodeKind::Rect {
1411                    width: 50.0,
1412                    height: 50.0,
1413                },
1414            ),
1415        );
1416        let _c = sg.add_node(
1417            sg.root,
1418            SceneNode::new(
1419                NodeId::intern("c"),
1420                NodeKind::Rect {
1421                    width: 50.0,
1422                    height: 50.0,
1423                },
1424            ),
1425        );
1426
1427        // Initial: [a, b, c]
1428        let ids: Vec<&str> = sg
1429            .children(sg.root)
1430            .iter()
1431            .map(|&i| sg.graph[i].id.as_str())
1432            .collect();
1433        assert_eq!(ids, vec!["a", "b", "c"]);
1434
1435        // Bring @a forward → should swap a and b → [b, a, c]
1436        let changed = sg.bring_forward(a);
1437        assert!(changed);
1438        let ids: Vec<&str> = sg
1439            .children(sg.root)
1440            .iter()
1441            .map(|&i| sg.graph[i].id.as_str())
1442            .collect();
1443        assert_eq!(ids, vec!["b", "a", "c"]);
1444    }
1445
1446    #[test]
1447    fn z_order_send_backward() {
1448        let mut sg = SceneGraph::new();
1449        let _a = sg.add_node(
1450            sg.root,
1451            SceneNode::new(
1452                NodeId::intern("a"),
1453                NodeKind::Rect {
1454                    width: 50.0,
1455                    height: 50.0,
1456                },
1457            ),
1458        );
1459        let _b = sg.add_node(
1460            sg.root,
1461            SceneNode::new(
1462                NodeId::intern("b"),
1463                NodeKind::Rect {
1464                    width: 50.0,
1465                    height: 50.0,
1466                },
1467            ),
1468        );
1469        let c = sg.add_node(
1470            sg.root,
1471            SceneNode::new(
1472                NodeId::intern("c"),
1473                NodeKind::Rect {
1474                    width: 50.0,
1475                    height: 50.0,
1476                },
1477            ),
1478        );
1479
1480        // Send @c backward → should swap c and b → [a, c, b]
1481        let changed = sg.send_backward(c);
1482        assert!(changed);
1483        let ids: Vec<&str> = sg
1484            .children(sg.root)
1485            .iter()
1486            .map(|&i| sg.graph[i].id.as_str())
1487            .collect();
1488        assert_eq!(ids, vec!["a", "c", "b"]);
1489    }
1490
1491    #[test]
1492    fn z_order_bring_to_front() {
1493        let mut sg = SceneGraph::new();
1494        let a = sg.add_node(
1495            sg.root,
1496            SceneNode::new(
1497                NodeId::intern("a"),
1498                NodeKind::Rect {
1499                    width: 50.0,
1500                    height: 50.0,
1501                },
1502            ),
1503        );
1504        let _b = sg.add_node(
1505            sg.root,
1506            SceneNode::new(
1507                NodeId::intern("b"),
1508                NodeKind::Rect {
1509                    width: 50.0,
1510                    height: 50.0,
1511                },
1512            ),
1513        );
1514        let _c = sg.add_node(
1515            sg.root,
1516            SceneNode::new(
1517                NodeId::intern("c"),
1518                NodeKind::Rect {
1519                    width: 50.0,
1520                    height: 50.0,
1521                },
1522            ),
1523        );
1524
1525        // Bring @a to front → [b, c, a]
1526        let changed = sg.bring_to_front(a);
1527        assert!(changed);
1528        let ids: Vec<&str> = sg
1529            .children(sg.root)
1530            .iter()
1531            .map(|&i| sg.graph[i].id.as_str())
1532            .collect();
1533        assert_eq!(ids, vec!["b", "c", "a"]);
1534    }
1535
1536    #[test]
1537    fn z_order_send_to_back() {
1538        let mut sg = SceneGraph::new();
1539        let _a = sg.add_node(
1540            sg.root,
1541            SceneNode::new(
1542                NodeId::intern("a"),
1543                NodeKind::Rect {
1544                    width: 50.0,
1545                    height: 50.0,
1546                },
1547            ),
1548        );
1549        let _b = sg.add_node(
1550            sg.root,
1551            SceneNode::new(
1552                NodeId::intern("b"),
1553                NodeKind::Rect {
1554                    width: 50.0,
1555                    height: 50.0,
1556                },
1557            ),
1558        );
1559        let c = sg.add_node(
1560            sg.root,
1561            SceneNode::new(
1562                NodeId::intern("c"),
1563                NodeKind::Rect {
1564                    width: 50.0,
1565                    height: 50.0,
1566                },
1567            ),
1568        );
1569
1570        // Send @c to back → [c, a, b]
1571        let changed = sg.send_to_back(c);
1572        assert!(changed);
1573        let ids: Vec<&str> = sg
1574            .children(sg.root)
1575            .iter()
1576            .map(|&i| sg.graph[i].id.as_str())
1577            .collect();
1578        assert_eq!(ids, vec!["c", "a", "b"]);
1579    }
1580
1581    #[test]
1582    fn z_order_emitter_roundtrip() {
1583        use crate::emitter::emit_document;
1584        use crate::parser::parse_document;
1585
1586        let mut sg = SceneGraph::new();
1587        let a = sg.add_node(
1588            sg.root,
1589            SceneNode::new(
1590                NodeId::intern("a"),
1591                NodeKind::Rect {
1592                    width: 50.0,
1593                    height: 50.0,
1594                },
1595            ),
1596        );
1597        let _b = sg.add_node(
1598            sg.root,
1599            SceneNode::new(
1600                NodeId::intern("b"),
1601                NodeKind::Rect {
1602                    width: 50.0,
1603                    height: 50.0,
1604                },
1605            ),
1606        );
1607        let _c = sg.add_node(
1608            sg.root,
1609            SceneNode::new(
1610                NodeId::intern("c"),
1611                NodeKind::Rect {
1612                    width: 50.0,
1613                    height: 50.0,
1614                },
1615            ),
1616        );
1617
1618        // Bring @a to front → [b, c, a]
1619        sg.bring_to_front(a);
1620
1621        // Emit and re-parse
1622        let text = emit_document(&sg);
1623        let reparsed = parse_document(&text).unwrap();
1624        let ids: Vec<&str> = reparsed
1625            .children(reparsed.root)
1626            .iter()
1627            .map(|&i| reparsed.graph[i].id.as_str())
1628            .collect();
1629        assert_eq!(
1630            ids,
1631            vec!["b", "c", "a"],
1632            "Z-order should survive emit→parse roundtrip. Emitted:\n{}",
1633            text
1634        );
1635    }
1636}