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 — no absolute coordinates are stored in the format.
7
8use crate::id::NodeId;
9use petgraph::graph::{DiGraph, NodeIndex};
10use serde::{Deserialize, Serialize};
11use smallvec::SmallVec;
12use std::collections::HashMap;
13
14// ─── Colors & Paint ──────────────────────────────────────────────────────
15
16/// RGBA color. Stored as 4 × f32 [0.0, 1.0].
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
18pub struct Color {
19    pub r: f32,
20    pub g: f32,
21    pub b: f32,
22    pub a: f32,
23}
24
25impl Color {
26    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
27        Self { r, g, b, a }
28    }
29
30    /// Parse a hex color string: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`.
31    pub fn from_hex(hex: &str) -> Option<Self> {
32        let hex = hex.strip_prefix('#')?;
33        match hex.len() {
34            3 => {
35                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
36                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
37                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
38                Some(Self::rgba(
39                    r as f32 / 255.0,
40                    g as f32 / 255.0,
41                    b as f32 / 255.0,
42                    1.0,
43                ))
44            }
45            4 => {
46                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
47                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
48                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
49                let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
50                Some(Self::rgba(
51                    r as f32 / 255.0,
52                    g as f32 / 255.0,
53                    b as f32 / 255.0,
54                    a as f32 / 255.0,
55                ))
56            }
57            6 => {
58                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
59                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
60                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
61                Some(Self::rgba(
62                    r as f32 / 255.0,
63                    g as f32 / 255.0,
64                    b as f32 / 255.0,
65                    1.0,
66                ))
67            }
68            8 => {
69                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
70                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
71                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
72                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
73                Some(Self::rgba(
74                    r as f32 / 255.0,
75                    g as f32 / 255.0,
76                    b as f32 / 255.0,
77                    a as f32 / 255.0,
78                ))
79            }
80            _ => None,
81        }
82    }
83
84    /// Emit as shortest valid hex string.
85    pub fn to_hex(&self) -> String {
86        let r = (self.r * 255.0).round() as u8;
87        let g = (self.g * 255.0).round() as u8;
88        let b = (self.b * 255.0).round() as u8;
89        let a = (self.a * 255.0).round() as u8;
90        if a == 255 {
91            format!("#{r:02X}{g:02X}{b:02X}")
92        } else {
93            format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
94        }
95    }
96}
97
98/// A gradient stop.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct GradientStop {
101    pub offset: f32, // 0.0 .. 1.0
102    pub color: Color,
103}
104
105/// Fill or stroke paint.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub enum Paint {
108    Solid(Color),
109    LinearGradient {
110        angle: f32, // degrees
111        stops: Vec<GradientStop>,
112    },
113    RadialGradient {
114        stops: Vec<GradientStop>,
115    },
116}
117
118// ─── Stroke ──────────────────────────────────────────────────────────────
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Stroke {
122    pub paint: Paint,
123    pub width: f32,
124    pub cap: StrokeCap,
125    pub join: StrokeJoin,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129pub enum StrokeCap {
130    Butt,
131    Round,
132    Square,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub enum StrokeJoin {
137    Miter,
138    Round,
139    Bevel,
140}
141
142impl Default for Stroke {
143    fn default() -> Self {
144        Self {
145            paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
146            width: 1.0,
147            cap: StrokeCap::Butt,
148            join: StrokeJoin::Miter,
149        }
150    }
151}
152
153// ─── Font / Text ─────────────────────────────────────────────────────────
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct FontSpec {
157    pub family: String,
158    pub weight: u16, // 100..900
159    pub size: f32,
160}
161
162impl Default for FontSpec {
163    fn default() -> Self {
164        Self {
165            family: "Inter".into(),
166            weight: 400,
167            size: 14.0,
168        }
169    }
170}
171
172// ─── Path data ───────────────────────────────────────────────────────────
173
174/// A single path command (SVG-like but simplified).
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub enum PathCmd {
177    MoveTo(f32, f32),
178    LineTo(f32, f32),
179    QuadTo(f32, f32, f32, f32),            // control, end
180    CubicTo(f32, f32, f32, f32, f32, f32), // c1, c2, end
181    Close,
182}
183
184// ─── Shadow ──────────────────────────────────────────────────────────────
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Shadow {
188    pub offset_x: f32,
189    pub offset_y: f32,
190    pub blur: f32,
191    pub color: Color,
192}
193
194// ─── Styling ─────────────────────────────────────────────────────────────
195
196/// A reusable style set that nodes can reference via `use: style_name`.
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct Style {
199    pub fill: Option<Paint>,
200    pub stroke: Option<Stroke>,
201    pub font: Option<FontSpec>,
202    pub corner_radius: Option<f32>,
203    pub opacity: Option<f32>,
204    pub shadow: Option<Shadow>,
205}
206
207// ─── Animation ───────────────────────────────────────────────────────────
208
209/// The trigger for an animation.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub enum AnimTrigger {
212    Hover,
213    Press,
214    Enter, // viewport enter
215    Custom(String),
216}
217
218/// Easing function.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub enum Easing {
221    Linear,
222    EaseIn,
223    EaseOut,
224    EaseInOut,
225    Spring,
226    CubicBezier(f32, f32, f32, f32),
227}
228
229/// A property animation keyframe.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct AnimKeyframe {
232    pub trigger: AnimTrigger,
233    pub duration_ms: u32,
234    pub easing: Easing,
235    pub properties: AnimProperties,
236}
237
238/// Animatable property overrides.
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct AnimProperties {
241    pub fill: Option<Paint>,
242    pub opacity: Option<f32>,
243    pub scale: Option<f32>,
244    pub rotate: Option<f32>, // degrees
245    pub translate: Option<(f32, f32)>,
246}
247
248// ─── Annotations ─────────────────────────────────────────────────────────
249
250/// Structured annotation attached to a scene node.
251/// Parsed from `##` lines in the FD format.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub enum Annotation {
254    /// Freeform description: `## "User auth entry point"`
255    Description(String),
256    /// Acceptance criterion: `## accept: "validates email on blur"`
257    Accept(String),
258    /// Status: `## status: draft`
259    Status(String),
260    /// Priority: `## priority: high`
261    Priority(String),
262    /// Tag: `## tag: auth`
263    Tag(String),
264}
265
266// ─── Layout Constraints ──────────────────────────────────────────────────
267
268/// Constraint-based layout — no absolute coordinates in the format.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub enum Constraint {
271    /// Center this node within a target (e.g. `canvas` or another node).
272    CenterIn(NodeId),
273    /// Position relative: dx, dy from a reference node.
274    Offset { from: NodeId, dx: f32, dy: f32 },
275    /// Fill the parent with optional padding.
276    FillParent { pad: f32 },
277    /// Explicit position (only used after layout resolution or for pinning).
278    Absolute { x: f32, y: f32 },
279}
280
281/// Group layout mode (for children arrangement).
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub enum LayoutMode {
284    /// Free / absolute positioning of children.
285    Free,
286    /// Column (vertical stack).
287    Column { gap: f32, pad: f32 },
288    /// Row (horizontal stack).
289    Row { gap: f32, pad: f32 },
290    /// Grid layout.
291    Grid { cols: u32, gap: f32, pad: f32 },
292}
293
294impl Default for LayoutMode {
295    fn default() -> Self {
296        Self::Free
297    }
298}
299
300// ─── Scene Graph Nodes ───────────────────────────────────────────────────
301
302/// The node kinds in the scene DAG.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub enum NodeKind {
305    /// Root of the document.
306    Root,
307
308    /// Group / frame — contains children.
309    Group { layout: LayoutMode },
310
311    /// Rectangle.
312    Rect { width: f32, height: f32 },
313
314    /// Ellipse / circle.
315    Ellipse { rx: f32, ry: f32 },
316
317    /// Freeform path (pen tool output).
318    Path { commands: Vec<PathCmd> },
319
320    /// Text label.
321    Text { content: String },
322}
323
324/// A single node in the scene graph.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SceneNode {
327    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
328    pub id: NodeId,
329
330    /// What kind of element this is.
331    pub kind: NodeKind,
332
333    /// Inline style overrides on this node.
334    pub style: Style,
335
336    /// Named style references (`use: base_text`).
337    pub use_styles: SmallVec<[NodeId; 2]>,
338
339    /// Constraint-based positioning.
340    pub constraints: SmallVec<[Constraint; 2]>,
341
342    /// Animations attached to this node.
343    pub animations: SmallVec<[AnimKeyframe; 2]>,
344
345    /// Structured annotations (`##` lines).
346    pub annotations: Vec<Annotation>,
347}
348
349impl SceneNode {
350    pub fn new(id: NodeId, kind: NodeKind) -> Self {
351        Self {
352            id,
353            kind,
354            style: Style::default(),
355            use_styles: SmallVec::new(),
356            constraints: SmallVec::new(),
357            animations: SmallVec::new(),
358            annotations: Vec::new(),
359        }
360    }
361}
362
363// ─── Scene Graph ─────────────────────────────────────────────────────────
364
365/// The complete FD document — a DAG of `SceneNode` values.
366///
367/// Edges go from parent → child. Style definitions are stored separately
368/// in a hashmap for lookup by name.
369#[derive(Debug, Clone)]
370pub struct SceneGraph {
371    /// The underlying directed graph.
372    pub graph: DiGraph<SceneNode, ()>,
373
374    /// The root node index.
375    pub root: NodeIndex,
376
377    /// Named style definitions (`style base_text { ... }`).
378    pub styles: HashMap<NodeId, Style>,
379
380    /// Index from NodeId → NodeIndex for fast lookup.
381    pub id_index: HashMap<NodeId, NodeIndex>,
382}
383
384impl SceneGraph {
385    /// Create a new empty scene graph with a root node.
386    #[must_use]
387    pub fn new() -> Self {
388        let mut graph = DiGraph::new();
389        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
390        let root = graph.add_node(root_node);
391
392        let mut id_index = HashMap::new();
393        id_index.insert(NodeId::intern("root"), root);
394
395        Self {
396            graph,
397            root,
398            styles: HashMap::new(),
399            id_index,
400        }
401    }
402
403    /// Add a node as a child of `parent`. Returns the new node's index.
404    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
405        let id = node.id;
406        let idx = self.graph.add_node(node);
407        self.graph.add_edge(parent, idx, ());
408        self.id_index.insert(id, idx);
409        idx
410    }
411
412    /// Look up a node by its `@id`.
413    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
414        self.id_index.get(&id).map(|idx| &self.graph[*idx])
415    }
416
417    /// Look up a node mutably by its `@id`.
418    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
419        self.id_index
420            .get(&id)
421            .copied()
422            .map(|idx| &mut self.graph[idx])
423    }
424
425    /// Get the index for a NodeId.
426    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
427        self.id_index.get(&id).copied()
428    }
429
430    /// Get children of a node in insertion order.
431    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
432        self.graph
433            .neighbors_directed(idx, petgraph::Direction::Outgoing)
434            .collect()
435    }
436
437    /// Define a named style.
438    pub fn define_style(&mut self, name: NodeId, style: Style) {
439        self.styles.insert(name, style);
440    }
441
442    /// Resolve a node's effective style (merging `use` references + inline overrides).
443    pub fn resolve_style(&self, node: &SceneNode) -> Style {
444        let mut resolved = Style::default();
445
446        // Apply referenced styles in order
447        for style_id in &node.use_styles {
448            if let Some(base) = self.styles.get(style_id) {
449                merge_style(&mut resolved, base);
450            }
451        }
452
453        // Apply inline overrides (take precedence)
454        merge_style(&mut resolved, &node.style);
455
456        resolved
457    }
458
459    /// Rebuild the `id_index` (needed after deserialization).
460    pub fn rebuild_index(&mut self) {
461        self.id_index.clear();
462        for idx in self.graph.node_indices() {
463            let id = self.graph[idx].id;
464            self.id_index.insert(id, idx);
465        }
466    }
467}
468
469impl Default for SceneGraph {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475/// Merge `src` style into `dst`, overwriting only `Some` fields.
476fn merge_style(dst: &mut Style, src: &Style) {
477    if src.fill.is_some() {
478        dst.fill = src.fill.clone();
479    }
480    if src.stroke.is_some() {
481        dst.stroke = src.stroke.clone();
482    }
483    if src.font.is_some() {
484        dst.font = src.font.clone();
485    }
486    if src.corner_radius.is_some() {
487        dst.corner_radius = src.corner_radius;
488    }
489    if src.opacity.is_some() {
490        dst.opacity = src.opacity;
491    }
492    if src.shadow.is_some() {
493        dst.shadow = src.shadow.clone();
494    }
495}
496
497// ─── Resolved positions (output of layout solver) ────────────────────────
498
499/// Resolved absolute bounding box after constraint solving.
500#[derive(Debug, Clone, Copy, Default)]
501pub struct ResolvedBounds {
502    pub x: f32,
503    pub y: f32,
504    pub width: f32,
505    pub height: f32,
506}
507
508impl ResolvedBounds {
509    pub fn contains(&self, px: f32, py: f32) -> bool {
510        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
511    }
512
513    pub fn center(&self) -> (f32, f32) {
514        (self.x + self.width / 2.0, self.y + self.height / 2.0)
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn scene_graph_basics() {
524        let mut sg = SceneGraph::new();
525        let rect = SceneNode::new(
526            NodeId::intern("box1"),
527            NodeKind::Rect {
528                width: 100.0,
529                height: 50.0,
530            },
531        );
532        let idx = sg.add_node(sg.root, rect);
533
534        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
535        assert_eq!(sg.children(sg.root).len(), 1);
536        assert_eq!(sg.children(sg.root)[0], idx);
537    }
538
539    #[test]
540    fn color_hex_roundtrip() {
541        let c = Color::from_hex("#6C5CE7").unwrap();
542        assert_eq!(c.to_hex(), "#6C5CE7");
543
544        let c2 = Color::from_hex("#FF000080").unwrap();
545        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
546        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
547    }
548
549    #[test]
550    fn style_merging() {
551        let mut sg = SceneGraph::new();
552        sg.define_style(
553            NodeId::intern("base"),
554            Style {
555                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
556                font: Some(FontSpec {
557                    family: "Inter".into(),
558                    weight: 400,
559                    size: 14.0,
560                }),
561                ..Default::default()
562            },
563        );
564
565        let mut node = SceneNode::new(
566            NodeId::intern("txt"),
567            NodeKind::Text {
568                content: "hi".into(),
569            },
570        );
571        node.use_styles.push(NodeId::intern("base"));
572        node.style.font = Some(FontSpec {
573            family: "Inter".into(),
574            weight: 700,
575            size: 24.0,
576        });
577
578        let resolved = sg.resolve_style(&node);
579        // Fill comes from base style
580        assert!(resolved.fill.is_some());
581        // Font comes from inline override
582        let f = resolved.font.unwrap();
583        assert_eq!(f.weight, 700);
584        assert_eq!(f.size, 24.0);
585    }
586}