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, PartialEq, 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, PartialEq, 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// ─── Image data ──────────────────────────────────────────────────────────
200
201/// Source for an embedded image.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub enum ImageSource {
204    /// Relative file path: `src: "assets/hero.png"`.
205    File(String),
206}
207
208/// How an image fits within its declared dimensions.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
210pub enum ImageFit {
211    /// Scale to cover bounds, crop overflow.
212    #[default]
213    Cover,
214    /// Scale to fit within bounds, letterbox.
215    Contain,
216    /// Stretch to exact dimensions.
217    Fill,
218    /// Natural size, no scaling.
219    None,
220}
221
222// ─── Shadow ──────────────────────────────────────────────────────────────
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Shadow {
226    pub offset_x: f32,
227    pub offset_y: f32,
228    pub blur: f32,
229    pub color: Color,
230}
231
232// ─── Styling ─────────────────────────────────────────────────────────────
233
234/// Horizontal text alignment.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
236pub enum TextAlign {
237    Left,
238    #[default]
239    Center,
240    Right,
241}
242
243/// Vertical text alignment.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
245pub enum TextVAlign {
246    Top,
247    #[default]
248    Middle,
249    Bottom,
250}
251
252/// Horizontal placement of a child within its parent.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
254pub enum HPlace {
255    Left,
256    #[default]
257    Center,
258    Right,
259}
260
261/// Vertical placement of a child within its parent.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
263pub enum VPlace {
264    Top,
265    #[default]
266    Middle,
267    Bottom,
268}
269
270/// A reusable style set that nodes can reference via `use: style_name`.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct Properties {
273    pub fill: Option<Paint>,
274    pub stroke: Option<Stroke>,
275    pub font: Option<FontSpec>,
276    pub corner_radius: Option<f32>,
277    pub opacity: Option<f32>,
278    pub shadow: Option<Shadow>,
279
280    /// Horizontal text alignment (default: Center).
281    pub text_align: Option<TextAlign>,
282    /// Vertical text alignment (default: Middle).
283    pub text_valign: Option<TextVAlign>,
284
285    /// Scale factor applied during rendering (from animations).
286    pub scale: Option<f32>,
287}
288
289// ─── Animation ───────────────────────────────────────────────────────────
290
291/// The trigger for an animation.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293pub enum AnimTrigger {
294    Hover,
295    Press,
296    Enter, // viewport enter
297    Custom(String),
298}
299
300/// Easing function.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub enum Easing {
303    Linear,
304    EaseIn,
305    EaseOut,
306    EaseInOut,
307    Spring,
308    CubicBezier(f32, f32, f32, f32),
309}
310
311/// A property animation keyframe.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct AnimKeyframe {
314    pub trigger: AnimTrigger,
315    pub duration_ms: u32,
316    pub easing: Easing,
317    pub properties: AnimProperties,
318}
319
320/// Animatable property overrides.
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct AnimProperties {
323    pub fill: Option<Paint>,
324    pub opacity: Option<f32>,
325    pub scale: Option<f32>,
326    pub rotate: Option<f32>, // degrees
327    pub translate: Option<(f32, f32)>,
328}
329
330// ─── Annotations ─────────────────────────────────────────────────────────
331
332/// Structured annotation attached to a scene node.
333/// Parsed from `spec { ... }` blocks in the FD format.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub enum Annotation {
336    /// Freeform description: `spec { "User auth entry point" }`
337    Description(String),
338    /// Acceptance criterion: `spec { accept: "validates email on blur" }`
339    Accept(String),
340    /// Status: `spec { status: todo }` (values: todo, doing, done, blocked)
341    Status(String),
342    /// Priority: `spec { priority: high }`
343    Priority(String),
344    /// Tag: `spec { tag: auth }`
345    Tag(String),
346}
347
348// ─── Imports ─────────────────────────────────────────────────────────────
349
350/// A file import declaration: `import "path.fd" as namespace`.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct Import {
353    /// Relative file path, e.g. "components/buttons.fd".
354    pub path: String,
355    /// Namespace alias, e.g. "buttons".
356    pub namespace: String,
357}
358
359// ─── Layout Constraints ──────────────────────────────────────────────────
360
361/// Constraint-based layout — no absolute coordinates in the format.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub enum Constraint {
364    /// Center this node within a target (e.g. `canvas` or another node).
365    CenterIn(NodeId),
366    /// Position relative: dx, dy from a reference node.
367    Offset { from: NodeId, dx: f32, dy: f32 },
368    /// Fill the parent with optional padding.
369    FillParent { pad: f32 },
370    /// Parent-relative position (used for drag-placed or pinned nodes).
371    /// Resolved as `parent.x + x`, `parent.y + y` by the layout solver.
372    Position { x: f32, y: f32 },
373}
374
375// ─── Edges (connections between nodes) ───────────────────────────────────
376
377/// Arrow head placement on an edge.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
379pub enum ArrowKind {
380    #[default]
381    None,
382    Start,
383    End,
384    Both,
385}
386
387/// How the edge path is drawn between two nodes.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
389pub enum CurveKind {
390    #[default]
391    Straight,
392    Smooth,
393    Step,
394}
395
396/// An edge endpoint — either connected to a node or a free point in scene-space.
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398pub enum EdgeAnchor {
399    /// Connected to a node center.
400    Node(NodeId),
401    /// Fixed position in scene-space (standalone arrow).
402    Point(f32, f32),
403}
404
405impl EdgeAnchor {
406    /// Return the NodeId if this is a Node anchor.
407    pub fn node_id(&self) -> Option<NodeId> {
408        match self {
409            Self::Node(id) => Some(*id),
410            Self::Point(_, _) => None,
411        }
412    }
413}
414
415/// Document-level default styles for edges.
416///
417/// When an `edge_defaults` block is present, individual edges omit
418/// properties that match the defaults — saving tokens for documents
419/// with many similarly styled edges.
420#[derive(Debug, Clone, Default, Serialize, Deserialize)]
421pub struct EdgeDefaults {
422    pub props: Properties,
423    pub arrow: Option<ArrowKind>,
424    pub curve: Option<CurveKind>,
425}
426
427/// A visual connection between two endpoints.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct Edge {
430    pub id: NodeId,
431    pub from: EdgeAnchor,
432    pub to: EdgeAnchor,
433    /// Optional text child node (max 1). The node lives in the SceneGraph.
434    pub text_child: Option<NodeId>,
435    pub props: Properties,
436    pub use_styles: SmallVec<[NodeId; 2]>,
437    pub arrow: ArrowKind,
438    pub curve: CurveKind,
439    pub annotations: Vec<Annotation>,
440    pub animations: SmallVec<[AnimKeyframe; 2]>,
441    pub flow: Option<FlowAnim>,
442    /// Offset of the edge text from the midpoint, set when label is dragged.
443    pub label_offset: Option<(f32, f32)>,
444}
445
446/// Flow animation kind — continuous motion along the edge path.
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448pub enum FlowKind {
449    /// A glowing dot traveling from → to on a loop.
450    Pulse,
451    /// Marching dashes along the edge (stroke-dashoffset animation).
452    Dash,
453}
454
455/// A flow animation attached to an edge.
456#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
457pub struct FlowAnim {
458    pub kind: FlowKind,
459    pub duration_ms: u32,
460}
461
462/// Group layout mode (for children arrangement).
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub enum LayoutMode {
465    /// Free / absolute positioning of children.
466    /// Optional padding insets the content area (default 0).
467    Free { pad: f32 },
468    /// Column (vertical stack).
469    Column { gap: f32, pad: f32 },
470    /// Row (horizontal stack).
471    Row { gap: f32, pad: f32 },
472    /// Grid layout.
473    Grid { cols: u32, gap: f32, pad: f32 },
474}
475
476impl Default for LayoutMode {
477    fn default() -> Self {
478        LayoutMode::Free { pad: 0.0 }
479    }
480}
481
482// ─── Scene Graph Nodes ───────────────────────────────────────────────────
483
484/// The node kinds in the scene DAG.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub enum NodeKind {
487    /// Root of the document.
488    Root,
489
490    /// Generic placeholder — no visual shape assigned yet.
491    /// Used for spec-only nodes: `@login_btn { spec "CTA" }`
492    Generic,
493
494    /// Organizational container (like Figma Group).
495    /// Auto-sizes to children, no own styles or layout modes.
496    Group,
497
498    /// Frame — visible container with explicit size and optional clipping.
499    /// Like a Figma frame: has fill/stroke, declared dimensions, clips overflow.
500    Frame {
501        width: f32,
502        height: f32,
503        clip: bool,
504        layout: LayoutMode,
505    },
506
507    /// Rectangle.
508    Rect { width: f32, height: f32 },
509
510    /// Ellipse / circle.
511    Ellipse { rx: f32, ry: f32 },
512
513    /// Freeform path (pen tool output).
514    Path { commands: Vec<PathCmd> },
515
516    /// Embedded image (R3.32).
517    Image {
518        source: ImageSource,
519        width: f32,
520        height: f32,
521        fit: ImageFit,
522    },
523
524    /// Text label. Optional `max_width` constrains horizontal extent
525    /// for word wrapping (set via resize handle drag).
526    Text {
527        content: String,
528        max_width: Option<f32>,
529    },
530}
531
532impl NodeKind {
533    /// Return the FD format keyword for this node kind.
534    pub fn kind_name(&self) -> &'static str {
535        match self {
536            Self::Root => "root",
537            Self::Generic => "generic",
538            Self::Group => "group",
539            Self::Frame { .. } => "frame",
540            Self::Rect { .. } => "rect",
541            Self::Ellipse { .. } => "ellipse",
542            Self::Path { .. } => "path",
543            Self::Image { .. } => "image",
544            Self::Text { .. } => "text",
545        }
546    }
547}
548
549/// A single node in the scene graph.
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct SceneNode {
552    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
553    pub id: NodeId,
554
555    /// What kind of element this is.
556    pub kind: NodeKind,
557
558    /// Inline style overrides on this node.
559    pub props: Properties,
560
561    /// Named style references (`use: base_text`).
562    pub use_styles: SmallVec<[NodeId; 2]>,
563
564    /// Constraint-based positioning.
565    pub constraints: SmallVec<[Constraint; 2]>,
566
567    /// Animations attached to this node.
568    pub animations: SmallVec<[AnimKeyframe; 2]>,
569
570    /// Structured annotations (`spec { ... }` block).
571    pub annotations: Vec<Annotation>,
572
573    /// Line comments (`# text`) that appeared before this node in the source.
574    /// Preserved across parse/emit round-trips so format passes don't delete them.
575    pub comments: Vec<String>,
576
577    /// 9-position placement of this child within its parent.
578    /// `None` = default positioning (auto-center for text, origin for others).
579    pub place: Option<(HPlace, VPlace)>,
580}
581
582impl SceneNode {
583    pub fn new(id: NodeId, kind: NodeKind) -> Self {
584        Self {
585            id,
586            kind,
587            props: Properties::default(),
588            use_styles: SmallVec::new(),
589            constraints: SmallVec::new(),
590            animations: SmallVec::new(),
591            annotations: Vec::new(),
592            comments: Vec::new(),
593            place: None,
594        }
595    }
596}
597
598// ─── Graph Snapshot (for ReadMode::Diff) ─────────────────────────────────
599
600/// A lightweight snapshot of graph state for diff computation.
601///
602/// Stores per-node and per-edge hashes so that changes can be detected
603/// by comparing hashes rather than storing full copies of the graph.
604#[derive(Debug, Clone, Default)]
605pub struct GraphSnapshot {
606    /// Hash of each node's emitted text, keyed by NodeId.
607    pub node_hashes: HashMap<NodeId, u64>,
608    /// Hash of each edge's emitted text, keyed by edge id.
609    pub edge_hashes: HashMap<NodeId, u64>,
610}
611
612// ─── Scene Graph ─────────────────────────────────────────────────────────
613
614/// The complete FD document — a DAG of `SceneNode` values.
615///
616/// Edges go from parent → child. Style definitions are stored separately
617/// in a hashmap for lookup by name.
618#[derive(Debug, Clone)]
619pub struct SceneGraph {
620    /// The underlying directed graph.
621    pub graph: StableDiGraph<SceneNode, ()>,
622
623    /// The root node index.
624    pub root: NodeIndex,
625
626    /// Named style definitions (`style base_text { ... }`).
627    pub styles: HashMap<NodeId, Properties>,
628
629    /// Index from NodeId → NodeIndex for fast lookup.
630    pub id_index: HashMap<NodeId, NodeIndex>,
631
632    /// Visual edges (connections between nodes).
633    pub edges: Vec<Edge>,
634
635    /// File imports with namespace aliases.
636    pub imports: Vec<Import>,
637
638    /// Explicit child ordering set by `sort_nodes`.
639    /// When present for a parent, `children()` returns this order
640    /// instead of the default `NodeIndex` sort.
641    pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
642
643    /// Document-level default styles for edges.
644    /// When present, individual edge properties matching the defaults are omitted.
645    pub edge_defaults: Option<EdgeDefaults>,
646}
647
648impl SceneGraph {
649    /// Create a new empty scene graph with a root node.
650    #[must_use]
651    pub fn new() -> Self {
652        let mut graph = StableDiGraph::new();
653        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
654        let root = graph.add_node(root_node);
655
656        let mut id_index = HashMap::new();
657        id_index.insert(NodeId::intern("root"), root);
658
659        Self {
660            graph,
661            root,
662            styles: HashMap::new(),
663            id_index,
664            edges: Vec::new(),
665            imports: Vec::new(),
666            sorted_child_order: HashMap::new(),
667            edge_defaults: None,
668        }
669    }
670
671    /// Add a node as a child of `parent`. Returns the new node's index.
672    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
673        let id = node.id;
674        let idx = self.graph.add_node(node);
675        self.graph.add_edge(parent, idx, ());
676        self.id_index.insert(id, idx);
677        idx
678    }
679
680    /// Remove a node safely, keeping the `id_index` synchronized.
681    pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
682        let removed = self.graph.remove_node(idx);
683        if let Some(removed_node) = &removed {
684            self.id_index.remove(&removed_node.id);
685        }
686        removed
687    }
688
689    /// Look up a node by its `@id`.
690    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
691        self.id_index.get(&id).map(|idx| &self.graph[*idx])
692    }
693
694    /// Look up a node mutably by its `@id`.
695    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
696        self.id_index
697            .get(&id)
698            .copied()
699            .map(|idx| &mut self.graph[idx])
700    }
701
702    /// Get the index for a NodeId.
703    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
704        self.id_index.get(&id).copied()
705    }
706
707    /// Get the parent index of a node.
708    pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
709        self.graph
710            .neighbors_directed(idx, petgraph::Direction::Incoming)
711            .next()
712    }
713
714    /// Reparent a node to a new parent.
715    pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
716        if let Some(old_parent) = self.parent(child)
717            && let Some(edge) = self.graph.find_edge(old_parent, child)
718        {
719            self.graph.remove_edge(edge);
720        }
721        self.graph.add_edge(new_parent, child, ());
722    }
723
724    /// Get children of a node in document (insertion) order.
725    ///
726    /// Sorts by `NodeIndex` so the result is deterministic regardless of
727    /// how `petgraph` iterates its adjacency list on different targets
728    /// (native vs WASM).
729    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
730        // If an explicit sort order was set (by sort_nodes), use it
731        if let Some(order) = self.sorted_child_order.get(&idx) {
732            return order.clone();
733        }
734
735        let mut children: Vec<NodeIndex> = self
736            .graph
737            .neighbors_directed(idx, petgraph::Direction::Outgoing)
738            .collect();
739        children.sort();
740        children
741    }
742
743    /// Move a child one step backward in z-order (swap with previous sibling).
744    /// Returns true if the z-order changed.
745    pub fn send_backward(&mut self, child: NodeIndex) -> bool {
746        let parent = match self.parent(child) {
747            Some(p) => p,
748            None => return false,
749        };
750        let siblings = self.children(parent);
751        let pos = match siblings.iter().position(|&s| s == child) {
752            Some(p) => p,
753            None => return false,
754        };
755        if pos == 0 {
756            return false; // already at back
757        }
758        // Rebuild edges in swapped order
759        self.rebuild_child_order(parent, &siblings, pos, pos - 1)
760    }
761
762    /// Move a child one step forward in z-order (swap with next sibling).
763    /// Returns true if the z-order changed.
764    pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
765        let parent = match self.parent(child) {
766            Some(p) => p,
767            None => return false,
768        };
769        let siblings = self.children(parent);
770        let pos = match siblings.iter().position(|&s| s == child) {
771            Some(p) => p,
772            None => return false,
773        };
774        if pos >= siblings.len() - 1 {
775            return false; // already at front
776        }
777        self.rebuild_child_order(parent, &siblings, pos, pos + 1)
778    }
779
780    /// Move a child to the back of z-order (first child).
781    pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
782        let parent = match self.parent(child) {
783            Some(p) => p,
784            None => return false,
785        };
786        let siblings = self.children(parent);
787        let pos = match siblings.iter().position(|&s| s == child) {
788            Some(p) => p,
789            None => return false,
790        };
791        if pos == 0 {
792            return false;
793        }
794        self.rebuild_child_order(parent, &siblings, pos, 0)
795    }
796
797    /// Move a child to the front of z-order (last child).
798    pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
799        let parent = match self.parent(child) {
800            Some(p) => p,
801            None => return false,
802        };
803        let siblings = self.children(parent);
804        let pos = match siblings.iter().position(|&s| s == child) {
805            Some(p) => p,
806            None => return false,
807        };
808        let last = siblings.len() - 1;
809        if pos == last {
810            return false;
811        }
812        self.rebuild_child_order(parent, &siblings, pos, last)
813    }
814
815    /// Rebuild child edges, moving child at `from` to `to` position.
816    fn rebuild_child_order(
817        &mut self,
818        parent: NodeIndex,
819        siblings: &[NodeIndex],
820        from: usize,
821        to: usize,
822    ) -> bool {
823        // Remove all edges from parent to children
824        for &sib in siblings {
825            if let Some(edge) = self.graph.find_edge(parent, sib) {
826                self.graph.remove_edge(edge);
827            }
828        }
829        // Build new order
830        let mut new_order: Vec<NodeIndex> = siblings.to_vec();
831        let child = new_order.remove(from);
832        new_order.insert(to, child);
833        // Re-add edges in new order
834        for &sib in &new_order {
835            self.graph.add_edge(parent, sib, ());
836        }
837        true
838    }
839
840    /// Define a named style.
841    pub fn define_style(&mut self, name: NodeId, style: Properties) {
842        self.styles.insert(name, style);
843    }
844
845    /// Resolve a node's effective style (merging `use` references + inline overrides + active animations).
846    pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Properties {
847        let mut resolved = Properties::default();
848
849        // Apply referenced styles in order
850        for style_id in &node.use_styles {
851            if let Some(base) = self.styles.get(style_id) {
852                merge_style(&mut resolved, base);
853            }
854        }
855
856        // Apply inline overrides (take precedence)
857        merge_style(&mut resolved, &node.props);
858
859        // Apply active animation state overrides
860        for anim in &node.animations {
861            if active_triggers.contains(&anim.trigger) {
862                if anim.properties.fill.is_some() {
863                    resolved.fill = anim.properties.fill.clone();
864                }
865                if anim.properties.opacity.is_some() {
866                    resolved.opacity = anim.properties.opacity;
867                }
868                if anim.properties.scale.is_some() {
869                    resolved.scale = anim.properties.scale;
870                }
871            }
872        }
873
874        resolved
875    }
876
877    /// Rebuild the `id_index` (needed after deserialization).
878    pub fn rebuild_index(&mut self) {
879        self.id_index.clear();
880        for idx in self.graph.node_indices() {
881            let id = self.graph[idx].id;
882            self.id_index.insert(id, idx);
883        }
884    }
885
886    /// Resolve an edge's effective style (merging `use` references + inline overrides + active animations).
887    pub fn resolve_style_for_edge(
888        &self,
889        edge: &Edge,
890        active_triggers: &[AnimTrigger],
891    ) -> Properties {
892        let mut resolved = Properties::default();
893        for style_id in &edge.use_styles {
894            if let Some(base) = self.styles.get(style_id) {
895                merge_style(&mut resolved, base);
896            }
897        }
898        merge_style(&mut resolved, &edge.props);
899
900        for anim in &edge.animations {
901            if active_triggers.contains(&anim.trigger) {
902                if anim.properties.fill.is_some() {
903                    resolved.fill = anim.properties.fill.clone();
904                }
905                if anim.properties.opacity.is_some() {
906                    resolved.opacity = anim.properties.opacity;
907                }
908                if anim.properties.scale.is_some() {
909                    resolved.scale = anim.properties.scale;
910                }
911            }
912        }
913
914        resolved
915    }
916
917    /// Resolve the effective click target for a leaf node.
918    ///
919    /// Figma-style group selection with progressive drill-down:
920    /// - **First click** → selects the topmost group ancestor (below root).
921    /// - **Click again** (topmost group already selected) → next-level group.
922    /// - **Click again** (all group ancestors selected) → the leaf itself.
923    pub fn effective_target(&self, leaf_id: NodeId, selected: &[NodeId]) -> NodeId {
924        let leaf_idx = match self.index_of(leaf_id) {
925            Some(idx) => idx,
926            None => return leaf_id,
927        };
928
929        // Walk up from the leaf, collecting group ancestors below root
930        // in bottom-up order.
931        let mut groups_bottom_up: Vec<NodeId> = Vec::new();
932        let mut cursor = self.parent(leaf_idx);
933        while let Some(parent_idx) = cursor {
934            if parent_idx == self.root {
935                break;
936            }
937            if matches!(self.graph[parent_idx].kind, NodeKind::Group) {
938                groups_bottom_up.push(self.graph[parent_idx].id);
939            }
940            cursor = self.parent(parent_idx);
941        }
942
943        // Reverse to get top-down order (topmost group first).
944        groups_bottom_up.reverse();
945
946        // Find the deepest selected group in the ancestor chain.
947        // If a selected node is in the chain, advance to the next level down.
948        let deepest_selected_pos = groups_bottom_up
949            .iter()
950            .rposition(|gid| selected.contains(gid));
951
952        match deepest_selected_pos {
953            None => {
954                // Nothing in the chain is selected → return topmost group
955                if let Some(top) = groups_bottom_up.first() {
956                    return *top;
957                }
958            }
959            Some(pos) if pos + 1 < groups_bottom_up.len() => {
960                // Selected group is not the deepest → advance one level
961                return groups_bottom_up[pos + 1];
962            }
963            Some(_) => {
964                // Deepest group is already selected → drill to leaf
965            }
966        }
967
968        leaf_id
969    }
970
971    /// Check if `ancestor_id` is a parent/grandparent/etc. of `descendant_id`.
972    pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
973        if ancestor_id == descendant_id {
974            return false;
975        }
976        let mut current_idx = match self.index_of(descendant_id) {
977            Some(idx) => idx,
978            None => return false,
979        };
980        while let Some(parent_idx) = self.parent(current_idx) {
981            if self.graph[parent_idx].id == ancestor_id {
982                return true;
983            }
984            if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
985                break;
986            }
987            current_idx = parent_idx;
988        }
989        false
990    }
991}
992
993impl Default for SceneGraph {
994    fn default() -> Self {
995        Self::new()
996    }
997}
998
999/// Merge `src` style into `dst`, overwriting only `Some` fields.
1000fn merge_style(dst: &mut Properties, src: &Properties) {
1001    if src.fill.is_some() {
1002        dst.fill = src.fill.clone();
1003    }
1004    if src.stroke.is_some() {
1005        dst.stroke = src.stroke.clone();
1006    }
1007    if src.font.is_some() {
1008        dst.font = src.font.clone();
1009    }
1010    if src.corner_radius.is_some() {
1011        dst.corner_radius = src.corner_radius;
1012    }
1013    if src.opacity.is_some() {
1014        dst.opacity = src.opacity;
1015    }
1016    if src.shadow.is_some() {
1017        dst.shadow = src.shadow.clone();
1018    }
1019
1020    if src.text_align.is_some() {
1021        dst.text_align = src.text_align;
1022    }
1023    if src.text_valign.is_some() {
1024        dst.text_valign = src.text_valign;
1025    }
1026    if src.scale.is_some() {
1027        dst.scale = src.scale;
1028    }
1029}
1030
1031// ─── Resolved positions (output of layout solver) ────────────────────────
1032
1033/// Resolved absolute bounding box after constraint solving.
1034#[derive(Debug, Clone, Copy, Default, PartialEq)]
1035pub struct ResolvedBounds {
1036    pub x: f32,
1037    pub y: f32,
1038    pub width: f32,
1039    pub height: f32,
1040}
1041
1042impl ResolvedBounds {
1043    pub fn contains(&self, px: f32, py: f32) -> bool {
1044        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
1045    }
1046
1047    pub fn center(&self) -> (f32, f32) {
1048        (self.x + self.width / 2.0, self.y + self.height / 2.0)
1049    }
1050
1051    /// Check if this bounds intersects with a rectangle (AABB overlap).
1052    pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
1053        self.x < rx + rw
1054            && self.x + self.width > rx
1055            && self.y < ry + rh
1056            && self.y + self.height > ry
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063
1064    #[test]
1065    fn scene_graph_basics() {
1066        let mut sg = SceneGraph::new();
1067        let rect = SceneNode::new(
1068            NodeId::intern("box1"),
1069            NodeKind::Rect {
1070                width: 100.0,
1071                height: 50.0,
1072            },
1073        );
1074        let idx = sg.add_node(sg.root, rect);
1075
1076        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
1077        assert_eq!(sg.children(sg.root).len(), 1);
1078        assert_eq!(sg.children(sg.root)[0], idx);
1079    }
1080
1081    #[test]
1082    fn color_hex_roundtrip() {
1083        let c = Color::from_hex("#6C5CE7").unwrap();
1084        assert_eq!(c.to_hex(), "#6C5CE7");
1085
1086        let c2 = Color::from_hex("#FF000080").unwrap();
1087        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
1088        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
1089    }
1090
1091    #[test]
1092    fn style_merging() {
1093        let mut sg = SceneGraph::new();
1094        sg.define_style(
1095            NodeId::intern("base"),
1096            Properties {
1097                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
1098                font: Some(FontSpec {
1099                    family: "Inter".into(),
1100                    weight: 400,
1101                    size: 14.0,
1102                }),
1103                ..Default::default()
1104            },
1105        );
1106
1107        let mut node = SceneNode::new(
1108            NodeId::intern("txt"),
1109            NodeKind::Text {
1110                content: "hi".into(),
1111                max_width: None,
1112            },
1113        );
1114        node.use_styles.push(NodeId::intern("base"));
1115        node.props.font = Some(FontSpec {
1116            family: "Inter".into(),
1117            weight: 700,
1118            size: 24.0,
1119        });
1120
1121        let resolved = sg.resolve_style(&node, &[]);
1122        // Fill comes from base style
1123        assert!(resolved.fill.is_some());
1124        // Font comes from inline override
1125        let f = resolved.font.unwrap();
1126        assert_eq!(f.weight, 700);
1127        assert_eq!(f.size, 24.0);
1128    }
1129
1130    #[test]
1131    fn style_merging_align() {
1132        let mut sg = SceneGraph::new();
1133        sg.define_style(
1134            NodeId::intern("centered"),
1135            Properties {
1136                text_align: Some(TextAlign::Center),
1137                text_valign: Some(TextVAlign::Middle),
1138                ..Default::default()
1139            },
1140        );
1141
1142        // Node with use: centered + inline override of text_align to Right
1143        let mut node = SceneNode::new(
1144            NodeId::intern("overridden"),
1145            NodeKind::Text {
1146                content: "hello".into(),
1147                max_width: None,
1148            },
1149        );
1150        node.use_styles.push(NodeId::intern("centered"));
1151        node.props.text_align = Some(TextAlign::Right);
1152
1153        let resolved = sg.resolve_style(&node, &[]);
1154        // Horizontal should be overridden to Right
1155        assert_eq!(resolved.text_align, Some(TextAlign::Right));
1156        // Vertical should come from base style (Middle)
1157        assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
1158    }
1159
1160    #[test]
1161    fn test_effective_target_group_selects_group_first() {
1162        let mut sg = SceneGraph::new();
1163
1164        // Root -> Group -> Rect
1165        let group_id = NodeId::intern("my_group");
1166        let rect_id = NodeId::intern("my_rect");
1167
1168        let group = SceneNode::new(group_id, NodeKind::Group);
1169        let rect = SceneNode::new(
1170            rect_id,
1171            NodeKind::Rect {
1172                width: 10.0,
1173                height: 10.0,
1174            },
1175        );
1176
1177        let group_idx = sg.add_node(sg.root, group);
1178        sg.add_node(group_idx, rect);
1179
1180        // Single click (nothing selected): should select the group
1181        assert_eq!(sg.effective_target(rect_id, &[]), group_id);
1182        // Double click (group already selected): drill down to leaf
1183        assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
1184        // Group itself → returns group (it IS the leaf in this call)
1185        assert_eq!(sg.effective_target(group_id, &[]), group_id);
1186    }
1187
1188    #[test]
1189    fn test_effective_target_nested_groups_selects_topmost() {
1190        let mut sg = SceneGraph::new();
1191
1192        // Root -> group_outer -> group_inner -> rect_leaf
1193        let outer_id = NodeId::intern("group_outer");
1194        let inner_id = NodeId::intern("group_inner");
1195        let leaf_id = NodeId::intern("rect_leaf");
1196
1197        let outer = SceneNode::new(outer_id, NodeKind::Group);
1198        let inner = SceneNode::new(inner_id, NodeKind::Group);
1199        let leaf = SceneNode::new(
1200            leaf_id,
1201            NodeKind::Rect {
1202                width: 50.0,
1203                height: 50.0,
1204            },
1205        );
1206
1207        let outer_idx = sg.add_node(sg.root, outer);
1208        let inner_idx = sg.add_node(outer_idx, inner);
1209        sg.add_node(inner_idx, leaf);
1210
1211        // Single click (nothing selected): topmost group
1212        assert_eq!(sg.effective_target(leaf_id, &[]), outer_id);
1213        // Outer selected → drill to inner group
1214        assert_eq!(sg.effective_target(leaf_id, &[outer_id]), inner_id);
1215        // Both outer+inner selected → drill to leaf
1216        assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
1217        // Non-cumulative: only inner selected (SelectTool replaces, not accumulates)
1218        // Must drill to leaf — NOT loop back to outer
1219        assert_eq!(sg.effective_target(leaf_id, &[inner_id]), leaf_id);
1220    }
1221
1222    #[test]
1223    fn test_effective_target_nested_drill_down_three_levels() {
1224        let mut sg = SceneGraph::new();
1225
1226        // Root -> group_a -> group_b -> group_c -> rect_leaf
1227        let a_id = NodeId::intern("group_a");
1228        let b_id = NodeId::intern("group_b");
1229        let c_id = NodeId::intern("group_c");
1230        let leaf_id = NodeId::intern("deep_leaf");
1231
1232        let a = SceneNode::new(a_id, NodeKind::Group);
1233        let b = SceneNode::new(b_id, NodeKind::Group);
1234        let c = SceneNode::new(c_id, NodeKind::Group);
1235        let leaf = SceneNode::new(
1236            leaf_id,
1237            NodeKind::Rect {
1238                width: 10.0,
1239                height: 10.0,
1240            },
1241        );
1242
1243        let a_idx = sg.add_node(sg.root, a);
1244        let b_idx = sg.add_node(a_idx, b);
1245        let c_idx = sg.add_node(b_idx, c);
1246        sg.add_node(c_idx, leaf);
1247
1248        // Progressive drill-down (non-cumulative — SelectTool replaces selection)
1249        assert_eq!(sg.effective_target(leaf_id, &[]), a_id);
1250        assert_eq!(sg.effective_target(leaf_id, &[a_id]), b_id);
1251        assert_eq!(sg.effective_target(leaf_id, &[b_id]), c_id);
1252        assert_eq!(sg.effective_target(leaf_id, &[c_id]), leaf_id);
1253    }
1254
1255    #[test]
1256    fn test_visual_highlight_differs_from_selected() {
1257        // Visual highlight contract: when effective_target returns a group,
1258        // the UI should highlight the raw hit (leaf) not the group.
1259        let mut sg = SceneGraph::new();
1260
1261        let group_id = NodeId::intern("card");
1262        let child_id = NodeId::intern("card_title");
1263
1264        let group = SceneNode::new(group_id, NodeKind::Group);
1265        let child = SceneNode::new(
1266            child_id,
1267            NodeKind::Text {
1268                content: "Title".into(),
1269                max_width: None,
1270            },
1271        );
1272
1273        let group_idx = sg.add_node(sg.root, group);
1274        sg.add_node(group_idx, child);
1275
1276        // Raw hit = child_id, nothing selected
1277        let logical_target = sg.effective_target(child_id, &[]);
1278        // Logical selection should be the group
1279        assert_eq!(logical_target, group_id);
1280        // Visual highlight should be the child (raw hit != logical_target)
1281        assert_ne!(child_id, logical_target);
1282        // After drilling (group selected), both converge
1283        let drilled = sg.effective_target(child_id, &[group_id]);
1284        assert_eq!(drilled, child_id);
1285    }
1286
1287    #[test]
1288    fn test_effective_target_no_group() {
1289        let mut sg = SceneGraph::new();
1290
1291        // Root -> Rect (no group)
1292        let rect_id = NodeId::intern("standalone_rect");
1293        let rect = SceneNode::new(
1294            rect_id,
1295            NodeKind::Rect {
1296                width: 10.0,
1297                height: 10.0,
1298            },
1299        );
1300        sg.add_node(sg.root, rect);
1301
1302        // No group parent → returns leaf directly
1303        assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
1304    }
1305
1306    #[test]
1307    fn test_is_ancestor_of() {
1308        let mut sg = SceneGraph::new();
1309
1310        // Root -> Group -> Rect
1311        let group_id = NodeId::intern("grp");
1312        let rect_id = NodeId::intern("r1");
1313        let other_id = NodeId::intern("other");
1314
1315        let group = SceneNode::new(group_id, NodeKind::Group);
1316        let rect = SceneNode::new(
1317            rect_id,
1318            NodeKind::Rect {
1319                width: 10.0,
1320                height: 10.0,
1321            },
1322        );
1323        let other = SceneNode::new(
1324            other_id,
1325            NodeKind::Rect {
1326                width: 5.0,
1327                height: 5.0,
1328            },
1329        );
1330
1331        let group_idx = sg.add_node(sg.root, group);
1332        sg.add_node(group_idx, rect);
1333        sg.add_node(sg.root, other);
1334
1335        // Group is ancestor of rect
1336        assert!(sg.is_ancestor_of(group_id, rect_id));
1337        // Root is ancestor of rect (grandparent)
1338        assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
1339        // Rect is NOT ancestor of group
1340        assert!(!sg.is_ancestor_of(rect_id, group_id));
1341        // Self is NOT ancestor of self
1342        assert!(!sg.is_ancestor_of(group_id, group_id));
1343        // Other is not ancestor of rect (sibling)
1344        assert!(!sg.is_ancestor_of(other_id, rect_id));
1345    }
1346
1347    #[test]
1348    fn test_resolve_style_scale_animation() {
1349        let sg = SceneGraph::new();
1350
1351        let mut node = SceneNode::new(
1352            NodeId::intern("btn"),
1353            NodeKind::Rect {
1354                width: 100.0,
1355                height: 40.0,
1356            },
1357        );
1358        node.props.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
1359        node.animations.push(AnimKeyframe {
1360            trigger: AnimTrigger::Press,
1361            duration_ms: 100,
1362            easing: Easing::EaseOut,
1363            properties: AnimProperties {
1364                scale: Some(0.97),
1365                ..Default::default()
1366            },
1367        });
1368
1369        // Without press trigger: scale should be None
1370        let resolved = sg.resolve_style(&node, &[]);
1371        assert!(resolved.scale.is_none());
1372
1373        // With press trigger: scale should be 0.97
1374        let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
1375        assert_eq!(resolved.scale, Some(0.97));
1376        // Fill should still be present
1377        assert!(resolved.fill.is_some());
1378    }
1379}