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/// Helper to parse a single hex digit.
28pub fn hex_val(c: u8) -> Option<u8> {
29    match c {
30        b'0'..=b'9' => Some(c - b'0'),
31        b'a'..=b'f' => Some(c - b'a' + 10),
32        b'A'..=b'F' => Some(c - b'A' + 10),
33        _ => None,
34    }
35}
36
37impl Color {
38    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
39        Self { r, g, b, a }
40    }
41
42    /// Parse a hex color string: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`.
43    /// The string may optionally start with `#`.
44    pub fn from_hex(hex: &str) -> Option<Self> {
45        let hex = hex.strip_prefix('#').unwrap_or(hex);
46        let bytes = hex.as_bytes();
47
48        match bytes.len() {
49            3 => {
50                let r = hex_val(bytes[0])?;
51                let g = hex_val(bytes[1])?;
52                let b = hex_val(bytes[2])?;
53                Some(Self::rgba(
54                    (r * 17) as f32 / 255.0,
55                    (g * 17) as f32 / 255.0,
56                    (b * 17) as f32 / 255.0,
57                    1.0,
58                ))
59            }
60            4 => {
61                let r = hex_val(bytes[0])?;
62                let g = hex_val(bytes[1])?;
63                let b = hex_val(bytes[2])?;
64                let a = hex_val(bytes[3])?;
65                Some(Self::rgba(
66                    (r * 17) as f32 / 255.0,
67                    (g * 17) as f32 / 255.0,
68                    (b * 17) as f32 / 255.0,
69                    (a * 17) as f32 / 255.0,
70                ))
71            }
72            6 => {
73                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
74                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
75                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
76                Some(Self::rgba(
77                    r as f32 / 255.0,
78                    g as f32 / 255.0,
79                    b as f32 / 255.0,
80                    1.0,
81                ))
82            }
83            8 => {
84                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
85                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
86                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
87                let a = hex_val(bytes[6])? << 4 | hex_val(bytes[7])?;
88                Some(Self::rgba(
89                    r as f32 / 255.0,
90                    g as f32 / 255.0,
91                    b as f32 / 255.0,
92                    a as f32 / 255.0,
93                ))
94            }
95            _ => None,
96        }
97    }
98
99    /// Emit as shortest valid hex string.
100    pub fn to_hex(&self) -> String {
101        let r = (self.r * 255.0).round() as u8;
102        let g = (self.g * 255.0).round() as u8;
103        let b = (self.b * 255.0).round() as u8;
104        let a = (self.a * 255.0).round() as u8;
105        if a == 255 {
106            format!("#{r:02X}{g:02X}{b:02X}")
107        } else {
108            format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
109        }
110    }
111}
112
113/// A gradient stop.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct GradientStop {
116    pub offset: f32, // 0.0 .. 1.0
117    pub color: Color,
118}
119
120/// Fill or stroke paint.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub enum Paint {
123    Solid(Color),
124    LinearGradient {
125        angle: f32, // degrees
126        stops: Vec<GradientStop>,
127    },
128    RadialGradient {
129        stops: Vec<GradientStop>,
130    },
131}
132
133// ─── Stroke ──────────────────────────────────────────────────────────────
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Stroke {
137    pub paint: Paint,
138    pub width: f32,
139    pub cap: StrokeCap,
140    pub join: StrokeJoin,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144pub enum StrokeCap {
145    Butt,
146    Round,
147    Square,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151pub enum StrokeJoin {
152    Miter,
153    Round,
154    Bevel,
155}
156
157impl Default for Stroke {
158    fn default() -> Self {
159        Self {
160            paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
161            width: 1.0,
162            cap: StrokeCap::Butt,
163            join: StrokeJoin::Miter,
164        }
165    }
166}
167
168// ─── Font / Text ─────────────────────────────────────────────────────────
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct FontSpec {
172    pub family: String,
173    pub weight: u16, // 100..900
174    pub size: f32,
175}
176
177impl Default for FontSpec {
178    fn default() -> Self {
179        Self {
180            family: "Inter".into(),
181            weight: 400,
182            size: 14.0,
183        }
184    }
185}
186
187// ─── Path data ───────────────────────────────────────────────────────────
188
189/// A single path command (SVG-like but simplified).
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub enum PathCmd {
192    MoveTo(f32, f32),
193    LineTo(f32, f32),
194    QuadTo(f32, f32, f32, f32),            // control, end
195    CubicTo(f32, f32, f32, f32, f32, f32), // c1, c2, end
196    Close,
197}
198
199// ─── Shadow ──────────────────────────────────────────────────────────────
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct Shadow {
203    pub offset_x: f32,
204    pub offset_y: f32,
205    pub blur: f32,
206    pub color: Color,
207}
208
209// ─── Styling ─────────────────────────────────────────────────────────────
210
211/// Horizontal text alignment.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
213pub enum TextAlign {
214    Left,
215    #[default]
216    Center,
217    Right,
218}
219
220/// Vertical text alignment.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
222pub enum TextVAlign {
223    Top,
224    #[default]
225    Middle,
226    Bottom,
227}
228
229/// A reusable theme set that nodes can reference via `use: theme_name`.
230#[derive(Debug, Clone, Default, Serialize, Deserialize)]
231pub struct Style {
232    pub fill: Option<Paint>,
233    pub stroke: Option<Stroke>,
234    pub font: Option<FontSpec>,
235    pub corner_radius: Option<f32>,
236    pub opacity: Option<f32>,
237    pub shadow: Option<Shadow>,
238
239    /// Horizontal text alignment (default: Center).
240    pub text_align: Option<TextAlign>,
241    /// Vertical text alignment (default: Middle).
242    pub text_valign: Option<TextVAlign>,
243
244    /// Scale factor applied during rendering (from animations).
245    pub scale: Option<f32>,
246}
247
248// ─── Animation ───────────────────────────────────────────────────────────
249
250/// The trigger for an animation.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub enum AnimTrigger {
253    Hover,
254    Press,
255    Enter, // viewport enter
256    Custom(String),
257}
258
259/// Easing function.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub enum Easing {
262    Linear,
263    EaseIn,
264    EaseOut,
265    EaseInOut,
266    Spring,
267    CubicBezier(f32, f32, f32, f32),
268}
269
270/// A property animation keyframe.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct AnimKeyframe {
273    pub trigger: AnimTrigger,
274    pub duration_ms: u32,
275    pub easing: Easing,
276    pub properties: AnimProperties,
277}
278
279/// Animatable property overrides.
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct AnimProperties {
282    pub fill: Option<Paint>,
283    pub opacity: Option<f32>,
284    pub scale: Option<f32>,
285    pub rotate: Option<f32>, // degrees
286    pub translate: Option<(f32, f32)>,
287}
288
289// ─── Annotations ─────────────────────────────────────────────────────────
290
291/// Structured annotation attached to a scene node.
292/// Parsed from `spec { ... }` blocks in the FD format.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub enum Annotation {
295    /// Freeform description: `spec { "User auth entry point" }`
296    Description(String),
297    /// Acceptance criterion: `spec { accept: "validates email on blur" }`
298    Accept(String),
299    /// Status: `spec { status: todo }` (values: todo, doing, done, blocked)
300    Status(String),
301    /// Priority: `spec { priority: high }`
302    Priority(String),
303    /// Tag: `spec { tag: auth }`
304    Tag(String),
305}
306
307// ─── Imports ─────────────────────────────────────────────────────────────
308
309/// A file import declaration: `import "path.fd" as namespace`.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct Import {
312    /// Relative file path, e.g. "components/buttons.fd".
313    pub path: String,
314    /// Namespace alias, e.g. "buttons".
315    pub namespace: String,
316}
317
318// ─── Layout Constraints ──────────────────────────────────────────────────
319
320/// Constraint-based layout — no absolute coordinates in the format.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum Constraint {
323    /// Center this node within a target (e.g. `canvas` or another node).
324    CenterIn(NodeId),
325    /// Position relative: dx, dy from a reference node.
326    Offset { from: NodeId, dx: f32, dy: f32 },
327    /// Fill the parent with optional padding.
328    FillParent { pad: f32 },
329    /// Parent-relative position (used for drag-placed or pinned nodes).
330    /// Resolved as `parent.x + x`, `parent.y + y` by the layout solver.
331    Position { x: f32, y: f32 },
332}
333
334// ─── Edges (connections between nodes) ───────────────────────────────────
335
336/// Arrow head placement on an edge.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
338pub enum ArrowKind {
339    #[default]
340    None,
341    Start,
342    End,
343    Both,
344}
345
346/// How the edge path is drawn between two nodes.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
348pub enum CurveKind {
349    #[default]
350    Straight,
351    Smooth,
352    Step,
353}
354
355/// An edge endpoint — either connected to a node or a free point in scene-space.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub enum EdgeAnchor {
358    /// Connected to a node center.
359    Node(NodeId),
360    /// Fixed position in scene-space (standalone arrow).
361    Point(f32, f32),
362}
363
364impl EdgeAnchor {
365    /// Return the NodeId if this is a Node anchor.
366    pub fn node_id(&self) -> Option<NodeId> {
367        match self {
368            Self::Node(id) => Some(*id),
369            Self::Point(_, _) => None,
370        }
371    }
372}
373
374/// A visual connection between two endpoints.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct Edge {
377    pub id: NodeId,
378    pub from: EdgeAnchor,
379    pub to: EdgeAnchor,
380    /// Optional text child node (max 1). The node lives in the SceneGraph.
381    pub text_child: Option<NodeId>,
382    pub style: Style,
383    pub use_styles: SmallVec<[NodeId; 2]>,
384    pub arrow: ArrowKind,
385    pub curve: CurveKind,
386    pub annotations: Vec<Annotation>,
387    pub animations: SmallVec<[AnimKeyframe; 2]>,
388    pub flow: Option<FlowAnim>,
389    /// Offset of the edge text from the midpoint, set when label is dragged.
390    pub label_offset: Option<(f32, f32)>,
391}
392
393/// Flow animation kind — continuous motion along the edge path.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395pub enum FlowKind {
396    /// A glowing dot traveling from → to on a loop.
397    Pulse,
398    /// Marching dashes along the edge (stroke-dashoffset animation).
399    Dash,
400}
401
402/// A flow animation attached to an edge.
403#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
404pub struct FlowAnim {
405    pub kind: FlowKind,
406    pub duration_ms: u32,
407}
408
409/// Group layout mode (for children arrangement).
410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
411pub enum LayoutMode {
412    /// Free / absolute positioning of children.
413    #[default]
414    Free,
415    /// Column (vertical stack).
416    Column { gap: f32, pad: f32 },
417    /// Row (horizontal stack).
418    Row { gap: f32, pad: f32 },
419    /// Grid layout.
420    Grid { cols: u32, gap: f32, pad: f32 },
421}
422
423// ─── Scene Graph Nodes ───────────────────────────────────────────────────
424
425/// The node kinds in the scene DAG.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub enum NodeKind {
428    /// Root of the document.
429    Root,
430
431    /// Generic placeholder — no visual shape assigned yet.
432    /// Used for spec-only nodes: `@login_btn { spec "CTA" }`
433    Generic,
434
435    /// Organizational container (like Figma Group).
436    /// Auto-sizes to children, no own styles or layout modes.
437    Group,
438
439    /// Frame — visible container with explicit size and optional clipping.
440    /// Like a Figma frame: has fill/stroke, declared dimensions, clips overflow.
441    Frame {
442        width: f32,
443        height: f32,
444        clip: bool,
445        layout: LayoutMode,
446    },
447
448    /// Rectangle.
449    Rect { width: f32, height: f32 },
450
451    /// Ellipse / circle.
452    Ellipse { rx: f32, ry: f32 },
453
454    /// Freeform path (pen tool output).
455    Path { commands: Vec<PathCmd> },
456
457    /// Text label.
458    Text { content: String },
459}
460
461impl NodeKind {
462    /// Return the FD format keyword for this node kind.
463    pub fn kind_name(&self) -> &'static str {
464        match self {
465            Self::Root => "root",
466            Self::Generic => "generic",
467            Self::Group => "group",
468            Self::Frame { .. } => "frame",
469            Self::Rect { .. } => "rect",
470            Self::Ellipse { .. } => "ellipse",
471            Self::Path { .. } => "path",
472            Self::Text { .. } => "text",
473        }
474    }
475}
476
477/// A single node in the scene graph.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct SceneNode {
480    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
481    pub id: NodeId,
482
483    /// What kind of element this is.
484    pub kind: NodeKind,
485
486    /// Inline style overrides on this node.
487    pub style: Style,
488
489    /// Named theme references (`use: base_text`).
490    pub use_styles: SmallVec<[NodeId; 2]>,
491
492    /// Constraint-based positioning.
493    pub constraints: SmallVec<[Constraint; 2]>,
494
495    /// Animations attached to this node.
496    pub animations: SmallVec<[AnimKeyframe; 2]>,
497
498    /// Structured annotations (`spec { ... }` block).
499    pub annotations: Vec<Annotation>,
500
501    /// Line comments (`# text`) that appeared before this node in the source.
502    /// Preserved across parse/emit round-trips so format passes don't delete them.
503    pub comments: Vec<String>,
504}
505
506impl SceneNode {
507    pub fn new(id: NodeId, kind: NodeKind) -> Self {
508        Self {
509            id,
510            kind,
511            style: Style::default(),
512            use_styles: SmallVec::new(),
513            constraints: SmallVec::new(),
514            animations: SmallVec::new(),
515            annotations: Vec::new(),
516            comments: Vec::new(),
517        }
518    }
519}
520
521// ─── Scene Graph ─────────────────────────────────────────────────────────
522
523/// The complete FD document — a DAG of `SceneNode` values.
524///
525/// Edges go from parent → child. Style definitions are stored separately
526/// in a hashmap for lookup by name.
527#[derive(Debug, Clone)]
528pub struct SceneGraph {
529    /// The underlying directed graph.
530    pub graph: StableDiGraph<SceneNode, ()>,
531
532    /// The root node index.
533    pub root: NodeIndex,
534
535    /// Named theme definitions (`theme base_text { ... }`).
536    pub styles: HashMap<NodeId, Style>,
537
538    /// Index from NodeId → NodeIndex for fast lookup.
539    pub id_index: HashMap<NodeId, NodeIndex>,
540
541    /// Visual edges (connections between nodes).
542    pub edges: Vec<Edge>,
543
544    /// File imports with namespace aliases.
545    pub imports: Vec<Import>,
546
547    /// Explicit child ordering set by `sort_nodes`.
548    /// When present for a parent, `children()` returns this order
549    /// instead of the default `NodeIndex` sort.
550    pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
551}
552
553impl SceneGraph {
554    /// Create a new empty scene graph with a root node.
555    #[must_use]
556    pub fn new() -> Self {
557        let mut graph = StableDiGraph::new();
558        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
559        let root = graph.add_node(root_node);
560
561        let mut id_index = HashMap::new();
562        id_index.insert(NodeId::intern("root"), root);
563
564        Self {
565            graph,
566            root,
567            styles: HashMap::new(),
568            id_index,
569            edges: Vec::new(),
570            imports: Vec::new(),
571            sorted_child_order: HashMap::new(),
572        }
573    }
574
575    /// Add a node as a child of `parent`. Returns the new node's index.
576    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
577        let id = node.id;
578        let idx = self.graph.add_node(node);
579        self.graph.add_edge(parent, idx, ());
580        self.id_index.insert(id, idx);
581        idx
582    }
583
584    /// Remove a node safely, keeping the `id_index` synchronized.
585    pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
586        let removed = self.graph.remove_node(idx);
587        if let Some(removed_node) = &removed {
588            self.id_index.remove(&removed_node.id);
589        }
590        removed
591    }
592
593    /// Look up a node by its `@id`.
594    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
595        self.id_index.get(&id).map(|idx| &self.graph[*idx])
596    }
597
598    /// Look up a node mutably by its `@id`.
599    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
600        self.id_index
601            .get(&id)
602            .copied()
603            .map(|idx| &mut self.graph[idx])
604    }
605
606    /// Get the index for a NodeId.
607    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
608        self.id_index.get(&id).copied()
609    }
610
611    /// Get the parent index of a node.
612    pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
613        self.graph
614            .neighbors_directed(idx, petgraph::Direction::Incoming)
615            .next()
616    }
617
618    /// Reparent a node to a new parent.
619    pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
620        if let Some(old_parent) = self.parent(child)
621            && let Some(edge) = self.graph.find_edge(old_parent, child)
622        {
623            self.graph.remove_edge(edge);
624        }
625        self.graph.add_edge(new_parent, child, ());
626    }
627
628    /// Get children of a node in document (insertion) order.
629    ///
630    /// Sorts by `NodeIndex` so the result is deterministic regardless of
631    /// how `petgraph` iterates its adjacency list on different targets
632    /// (native vs WASM).
633    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
634        // If an explicit sort order was set (by sort_nodes), use it
635        if let Some(order) = self.sorted_child_order.get(&idx) {
636            return order.clone();
637        }
638
639        let mut children: Vec<NodeIndex> = self
640            .graph
641            .neighbors_directed(idx, petgraph::Direction::Outgoing)
642            .collect();
643        children.sort();
644        children
645    }
646
647    /// Move a child one step backward in z-order (swap with previous sibling).
648    /// Returns true if the z-order changed.
649    pub fn send_backward(&mut self, child: NodeIndex) -> bool {
650        let parent = match self.parent(child) {
651            Some(p) => p,
652            None => return false,
653        };
654        let siblings = self.children(parent);
655        let pos = match siblings.iter().position(|&s| s == child) {
656            Some(p) => p,
657            None => return false,
658        };
659        if pos == 0 {
660            return false; // already at back
661        }
662        // Rebuild edges in swapped order
663        self.rebuild_child_order(parent, &siblings, pos, pos - 1)
664    }
665
666    /// Move a child one step forward in z-order (swap with next sibling).
667    /// Returns true if the z-order changed.
668    pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
669        let parent = match self.parent(child) {
670            Some(p) => p,
671            None => return false,
672        };
673        let siblings = self.children(parent);
674        let pos = match siblings.iter().position(|&s| s == child) {
675            Some(p) => p,
676            None => return false,
677        };
678        if pos >= siblings.len() - 1 {
679            return false; // already at front
680        }
681        self.rebuild_child_order(parent, &siblings, pos, pos + 1)
682    }
683
684    /// Move a child to the back of z-order (first child).
685    pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
686        let parent = match self.parent(child) {
687            Some(p) => p,
688            None => return false,
689        };
690        let siblings = self.children(parent);
691        let pos = match siblings.iter().position(|&s| s == child) {
692            Some(p) => p,
693            None => return false,
694        };
695        if pos == 0 {
696            return false;
697        }
698        self.rebuild_child_order(parent, &siblings, pos, 0)
699    }
700
701    /// Move a child to the front of z-order (last child).
702    pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
703        let parent = match self.parent(child) {
704            Some(p) => p,
705            None => return false,
706        };
707        let siblings = self.children(parent);
708        let pos = match siblings.iter().position(|&s| s == child) {
709            Some(p) => p,
710            None => return false,
711        };
712        let last = siblings.len() - 1;
713        if pos == last {
714            return false;
715        }
716        self.rebuild_child_order(parent, &siblings, pos, last)
717    }
718
719    /// Rebuild child edges, moving child at `from` to `to` position.
720    fn rebuild_child_order(
721        &mut self,
722        parent: NodeIndex,
723        siblings: &[NodeIndex],
724        from: usize,
725        to: usize,
726    ) -> bool {
727        // Remove all edges from parent to children
728        for &sib in siblings {
729            if let Some(edge) = self.graph.find_edge(parent, sib) {
730                self.graph.remove_edge(edge);
731            }
732        }
733        // Build new order
734        let mut new_order: Vec<NodeIndex> = siblings.to_vec();
735        let child = new_order.remove(from);
736        new_order.insert(to, child);
737        // Re-add edges in new order
738        for &sib in &new_order {
739            self.graph.add_edge(parent, sib, ());
740        }
741        true
742    }
743
744    /// Define a named style.
745    pub fn define_style(&mut self, name: NodeId, style: Style) {
746        self.styles.insert(name, style);
747    }
748
749    /// Resolve a node's effective style (merging `use` references + inline overrides + active animations).
750    pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Style {
751        let mut resolved = Style::default();
752
753        // Apply referenced styles in order
754        for style_id in &node.use_styles {
755            if let Some(base) = self.styles.get(style_id) {
756                merge_style(&mut resolved, base);
757            }
758        }
759
760        // Apply inline overrides (take precedence)
761        merge_style(&mut resolved, &node.style);
762
763        // Apply active animation state overrides
764        for anim in &node.animations {
765            if active_triggers.contains(&anim.trigger) {
766                if anim.properties.fill.is_some() {
767                    resolved.fill = anim.properties.fill.clone();
768                }
769                if anim.properties.opacity.is_some() {
770                    resolved.opacity = anim.properties.opacity;
771                }
772                if anim.properties.scale.is_some() {
773                    resolved.scale = anim.properties.scale;
774                }
775            }
776        }
777
778        resolved
779    }
780
781    /// Rebuild the `id_index` (needed after deserialization).
782    pub fn rebuild_index(&mut self) {
783        self.id_index.clear();
784        for idx in self.graph.node_indices() {
785            let id = self.graph[idx].id;
786            self.id_index.insert(id, idx);
787        }
788    }
789
790    /// Resolve an edge's effective style (merging `use` references + inline overrides + active animations).
791    pub fn resolve_style_for_edge(&self, edge: &Edge, active_triggers: &[AnimTrigger]) -> Style {
792        let mut resolved = Style::default();
793        for style_id in &edge.use_styles {
794            if let Some(base) = self.styles.get(style_id) {
795                merge_style(&mut resolved, base);
796            }
797        }
798        merge_style(&mut resolved, &edge.style);
799
800        for anim in &edge.animations {
801            if active_triggers.contains(&anim.trigger) {
802                if anim.properties.fill.is_some() {
803                    resolved.fill = anim.properties.fill.clone();
804                }
805                if anim.properties.opacity.is_some() {
806                    resolved.opacity = anim.properties.opacity;
807                }
808                if anim.properties.scale.is_some() {
809                    resolved.scale = anim.properties.scale;
810                }
811            }
812        }
813
814        resolved
815    }
816
817    /// Resolve the effective click target for a leaf node.
818    ///
819    /// Figma-style group selection with progressive drill-down:
820    /// - **First click** → selects the topmost group ancestor (below root).
821    /// - **Click again** (topmost group already selected) → next-level group.
822    /// - **Click again** (all group ancestors selected) → the leaf itself.
823    pub fn effective_target(&self, leaf_id: NodeId, selected: &[NodeId]) -> NodeId {
824        let leaf_idx = match self.index_of(leaf_id) {
825            Some(idx) => idx,
826            None => return leaf_id,
827        };
828
829        // Walk up from the leaf, collecting group ancestors below root
830        // in bottom-up order.
831        let mut groups_bottom_up: Vec<NodeId> = Vec::new();
832        let mut cursor = self.parent(leaf_idx);
833        while let Some(parent_idx) = cursor {
834            if parent_idx == self.root {
835                break;
836            }
837            if matches!(self.graph[parent_idx].kind, NodeKind::Group) {
838                groups_bottom_up.push(self.graph[parent_idx].id);
839            }
840            cursor = self.parent(parent_idx);
841        }
842
843        // Reverse to get top-down order (topmost group first).
844        groups_bottom_up.reverse();
845
846        // Find the deepest unselected group in the chain.
847        for group_id in &groups_bottom_up {
848            if !selected.contains(group_id) {
849                return *group_id;
850            }
851        }
852
853        // All groups selected → drill down to the leaf.
854        leaf_id
855    }
856
857    /// Check if `ancestor_id` is a parent/grandparent/etc. of `descendant_id`.
858    pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
859        if ancestor_id == descendant_id {
860            return false;
861        }
862        let mut current_idx = match self.index_of(descendant_id) {
863            Some(idx) => idx,
864            None => return false,
865        };
866        while let Some(parent_idx) = self.parent(current_idx) {
867            if self.graph[parent_idx].id == ancestor_id {
868                return true;
869            }
870            if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
871                break;
872            }
873            current_idx = parent_idx;
874        }
875        false
876    }
877}
878
879impl Default for SceneGraph {
880    fn default() -> Self {
881        Self::new()
882    }
883}
884
885/// Merge `src` style into `dst`, overwriting only `Some` fields.
886fn merge_style(dst: &mut Style, src: &Style) {
887    if src.fill.is_some() {
888        dst.fill = src.fill.clone();
889    }
890    if src.stroke.is_some() {
891        dst.stroke = src.stroke.clone();
892    }
893    if src.font.is_some() {
894        dst.font = src.font.clone();
895    }
896    if src.corner_radius.is_some() {
897        dst.corner_radius = src.corner_radius;
898    }
899    if src.opacity.is_some() {
900        dst.opacity = src.opacity;
901    }
902    if src.shadow.is_some() {
903        dst.shadow = src.shadow.clone();
904    }
905
906    if src.text_align.is_some() {
907        dst.text_align = src.text_align;
908    }
909    if src.text_valign.is_some() {
910        dst.text_valign = src.text_valign;
911    }
912    if src.scale.is_some() {
913        dst.scale = src.scale;
914    }
915}
916
917// ─── Resolved positions (output of layout solver) ────────────────────────
918
919/// Resolved absolute bounding box after constraint solving.
920#[derive(Debug, Clone, Copy, Default, PartialEq)]
921pub struct ResolvedBounds {
922    pub x: f32,
923    pub y: f32,
924    pub width: f32,
925    pub height: f32,
926}
927
928impl ResolvedBounds {
929    pub fn contains(&self, px: f32, py: f32) -> bool {
930        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
931    }
932
933    pub fn center(&self) -> (f32, f32) {
934        (self.x + self.width / 2.0, self.y + self.height / 2.0)
935    }
936
937    /// Check if this bounds intersects with a rectangle (AABB overlap).
938    pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
939        self.x < rx + rw
940            && self.x + self.width > rx
941            && self.y < ry + rh
942            && self.y + self.height > ry
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    #[test]
951    fn scene_graph_basics() {
952        let mut sg = SceneGraph::new();
953        let rect = SceneNode::new(
954            NodeId::intern("box1"),
955            NodeKind::Rect {
956                width: 100.0,
957                height: 50.0,
958            },
959        );
960        let idx = sg.add_node(sg.root, rect);
961
962        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
963        assert_eq!(sg.children(sg.root).len(), 1);
964        assert_eq!(sg.children(sg.root)[0], idx);
965    }
966
967    #[test]
968    fn color_hex_roundtrip() {
969        let c = Color::from_hex("#6C5CE7").unwrap();
970        assert_eq!(c.to_hex(), "#6C5CE7");
971
972        let c2 = Color::from_hex("#FF000080").unwrap();
973        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
974        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
975    }
976
977    #[test]
978    fn style_merging() {
979        let mut sg = SceneGraph::new();
980        sg.define_style(
981            NodeId::intern("base"),
982            Style {
983                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
984                font: Some(FontSpec {
985                    family: "Inter".into(),
986                    weight: 400,
987                    size: 14.0,
988                }),
989                ..Default::default()
990            },
991        );
992
993        let mut node = SceneNode::new(
994            NodeId::intern("txt"),
995            NodeKind::Text {
996                content: "hi".into(),
997            },
998        );
999        node.use_styles.push(NodeId::intern("base"));
1000        node.style.font = Some(FontSpec {
1001            family: "Inter".into(),
1002            weight: 700,
1003            size: 24.0,
1004        });
1005
1006        let resolved = sg.resolve_style(&node, &[]);
1007        // Fill comes from base style
1008        assert!(resolved.fill.is_some());
1009        // Font comes from inline override
1010        let f = resolved.font.unwrap();
1011        assert_eq!(f.weight, 700);
1012        assert_eq!(f.size, 24.0);
1013    }
1014
1015    #[test]
1016    fn style_merging_align() {
1017        let mut sg = SceneGraph::new();
1018        sg.define_style(
1019            NodeId::intern("centered"),
1020            Style {
1021                text_align: Some(TextAlign::Center),
1022                text_valign: Some(TextVAlign::Middle),
1023                ..Default::default()
1024            },
1025        );
1026
1027        // Node with use: centered + inline override of text_align to Right
1028        let mut node = SceneNode::new(
1029            NodeId::intern("overridden"),
1030            NodeKind::Text {
1031                content: "hello".into(),
1032            },
1033        );
1034        node.use_styles.push(NodeId::intern("centered"));
1035        node.style.text_align = Some(TextAlign::Right);
1036
1037        let resolved = sg.resolve_style(&node, &[]);
1038        // Horizontal should be overridden to Right
1039        assert_eq!(resolved.text_align, Some(TextAlign::Right));
1040        // Vertical should come from base style (Middle)
1041        assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
1042    }
1043
1044    #[test]
1045    fn test_effective_target_group_selects_group_first() {
1046        let mut sg = SceneGraph::new();
1047
1048        // Root -> Group -> Rect
1049        let group_id = NodeId::intern("my_group");
1050        let rect_id = NodeId::intern("my_rect");
1051
1052        let group = SceneNode::new(group_id, NodeKind::Group);
1053        let rect = SceneNode::new(
1054            rect_id,
1055            NodeKind::Rect {
1056                width: 10.0,
1057                height: 10.0,
1058            },
1059        );
1060
1061        let group_idx = sg.add_node(sg.root, group);
1062        sg.add_node(group_idx, rect);
1063
1064        // Single click (nothing selected): should select the group
1065        assert_eq!(sg.effective_target(rect_id, &[]), group_id);
1066        // Double click (group already selected): drill down to leaf
1067        assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
1068        // Group itself → returns group (it IS the leaf in this call)
1069        assert_eq!(sg.effective_target(group_id, &[]), group_id);
1070    }
1071
1072    #[test]
1073    fn test_effective_target_nested_groups_selects_topmost() {
1074        let mut sg = SceneGraph::new();
1075
1076        // Root -> group_outer -> group_inner -> rect_leaf
1077        let outer_id = NodeId::intern("group_outer");
1078        let inner_id = NodeId::intern("group_inner");
1079        let leaf_id = NodeId::intern("rect_leaf");
1080
1081        let outer = SceneNode::new(outer_id, NodeKind::Group);
1082        let inner = SceneNode::new(inner_id, NodeKind::Group);
1083        let leaf = SceneNode::new(
1084            leaf_id,
1085            NodeKind::Rect {
1086                width: 50.0,
1087                height: 50.0,
1088            },
1089        );
1090
1091        let outer_idx = sg.add_node(sg.root, outer);
1092        let inner_idx = sg.add_node(outer_idx, inner);
1093        sg.add_node(inner_idx, leaf);
1094
1095        // Single click (nothing selected): topmost group
1096        assert_eq!(sg.effective_target(leaf_id, &[]), outer_id);
1097        // Outer selected → drill to inner group
1098        assert_eq!(sg.effective_target(leaf_id, &[outer_id]), inner_id);
1099        // Both outer+inner selected → drill to leaf
1100        assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
1101    }
1102
1103    #[test]
1104    fn test_effective_target_no_group() {
1105        let mut sg = SceneGraph::new();
1106
1107        // Root -> Rect (no group)
1108        let rect_id = NodeId::intern("standalone_rect");
1109        let rect = SceneNode::new(
1110            rect_id,
1111            NodeKind::Rect {
1112                width: 10.0,
1113                height: 10.0,
1114            },
1115        );
1116        sg.add_node(sg.root, rect);
1117
1118        // No group parent → returns leaf directly
1119        assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
1120    }
1121
1122    #[test]
1123    fn test_is_ancestor_of() {
1124        let mut sg = SceneGraph::new();
1125
1126        // Root -> Group -> Rect
1127        let group_id = NodeId::intern("grp");
1128        let rect_id = NodeId::intern("r1");
1129        let other_id = NodeId::intern("other");
1130
1131        let group = SceneNode::new(group_id, NodeKind::Group);
1132        let rect = SceneNode::new(
1133            rect_id,
1134            NodeKind::Rect {
1135                width: 10.0,
1136                height: 10.0,
1137            },
1138        );
1139        let other = SceneNode::new(
1140            other_id,
1141            NodeKind::Rect {
1142                width: 5.0,
1143                height: 5.0,
1144            },
1145        );
1146
1147        let group_idx = sg.add_node(sg.root, group);
1148        sg.add_node(group_idx, rect);
1149        sg.add_node(sg.root, other);
1150
1151        // Group is ancestor of rect
1152        assert!(sg.is_ancestor_of(group_id, rect_id));
1153        // Root is ancestor of rect (grandparent)
1154        assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
1155        // Rect is NOT ancestor of group
1156        assert!(!sg.is_ancestor_of(rect_id, group_id));
1157        // Self is NOT ancestor of self
1158        assert!(!sg.is_ancestor_of(group_id, group_id));
1159        // Other is not ancestor of rect (sibling)
1160        assert!(!sg.is_ancestor_of(other_id, rect_id));
1161    }
1162
1163    #[test]
1164    fn test_resolve_style_scale_animation() {
1165        let sg = SceneGraph::new();
1166
1167        let mut node = SceneNode::new(
1168            NodeId::intern("btn"),
1169            NodeKind::Rect {
1170                width: 100.0,
1171                height: 40.0,
1172            },
1173        );
1174        node.style.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
1175        node.animations.push(AnimKeyframe {
1176            trigger: AnimTrigger::Press,
1177            duration_ms: 100,
1178            easing: Easing::EaseOut,
1179            properties: AnimProperties {
1180                scale: Some(0.97),
1181                ..Default::default()
1182            },
1183        });
1184
1185        // Without press trigger: scale should be None
1186        let resolved = sg.resolve_style(&node, &[]);
1187        assert!(resolved.scale.is_none());
1188
1189        // With press trigger: scale should be 0.97
1190        let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
1191        assert_eq!(resolved.scale, Some(0.97));
1192        // Fill should still be present
1193        assert!(resolved.fill.is_some());
1194    }
1195}