//! Nodes: one struct with the properties every node can have, and a kind with per-type data. use crate::NodeId; use crate::props::*; use crate::proto::{Interaction, Overflow, ScrollPosition}; use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId}; use crate::text::TextData; use crate::vector::VectorNetwork; use figcraft_color::{BlendMode, Color, Paint}; use figcraft_geom::Affine; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; /// Boolean operation of a boolean group. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum BoolOp { #[default] Union, Subtract, Intersect, Exclude, } impl BoolOp { pub fn label(self) -> &'static str { match self { BoolOp::Union => "Union", BoolOp::Subtract => "Subtract", BoolOp::Intersect => "Intersect", BoolOp::Exclude => "Exclude", } } } /// A component property definition. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PropDef { Boolean { default: bool, }, Text { default: String, }, /// A nested instance's main; `preferred` components (or sets) are offered first when /// swapping it. InstanceSwap { default: NodeId, #[serde(default, skip_serializing_if = "Vec::is_empty")] preferred: Vec, }, Variant { options: Vec, default: String, }, /// A frame (bound through `prop_refs["slot"]`) whose instances take any content of their own; /// `preferred` components are offered first when filling it. Slot { #[serde(default)] preferred: Vec, }, } /// What a node is, with per-type data. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum NodeKind { Page { #[serde(default = "page_bg")] background: Color, #[serde(default)] flows: Vec, #[serde(default)] prototype: crate::proto::PrototypeSettings, #[serde(default)] guides: Vec, }, Frame, Group, Section { #[serde(default)] contents_hidden: bool, }, Rectangle, Ellipse { #[serde(default)] start: f64, #[serde(default = "tau")] sweep: f64, #[serde(default)] inner: f64, }, Polygon { count: u32, }, Star { count: u32, inner_ratio: f64, }, Line, Vector { network: VectorNetwork, }, Text(TextData), BooleanOp { op: BoolOp, }, Component { #[serde(default)] description: String, #[serde(default)] props: BTreeMap, }, ComponentSet { #[serde(default)] description: String, }, Instance { component: NodeId, /// Values for the component's properties. #[serde(default)] props: BTreeMap, }, Slice, } fn page_bg() -> Color { Color::DEFAULT_PAGE } fn tau() -> f64 { std::f64::consts::TAU } /// How a mask layer masks the layers above it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum MaskType { /// The mask's rendered alpha (soft edges, gradients, opacity). #[default] Alpha, /// The mask's outline geometry, fully opaque (fills and opacity don't matter). Vector, /// The mask's brightness: white shows, black hides. Luminance, } impl MaskType { pub const ALL: [MaskType; 3] = [MaskType::Alpha, MaskType::Vector, MaskType::Luminance]; pub fn is_alpha(&self) -> bool { *self == MaskType::Alpha } pub fn id(self) -> &'static str { match self { MaskType::Alpha => "alpha", MaskType::Vector => "vector", MaskType::Luminance => "luminance", } } pub fn label(self) -> &'static str { match self { MaskType::Alpha => "Alpha", MaskType::Vector => "Vector", MaskType::Luminance => "Luminance", } } } /// Handoff status of a frame or section. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum DevStatus { #[default] None, ReadyForDev, Completed, } impl DevStatus { pub const ALL: [DevStatus; 3] = [DevStatus::None, DevStatus::ReadyForDev, DevStatus::Completed]; pub fn is_none(&self) -> bool { *self == DevStatus::None } pub fn id(self) -> &'static str { match self { DevStatus::None => "none", DevStatus::ReadyForDev => "readyForDev", DevStatus::Completed => "completed", } } pub fn label(self) -> &'static str { match self { DevStatus::None => "No status", DevStatus::ReadyForDev => "Ready for dev", DevStatus::Completed => "Completed", } } } /// A note for developers on a layer (a Dev Mode annotation): its text and the properties it pins, /// which Dev Mode shows with their current values. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Annotation { #[serde(default, skip_serializing_if = "String::is_empty")] pub label: String, /// Pinned property ids ("width", "fill", "fontSize"…; `figcraft_engine::annotations` lists /// them). Unknown ids are kept and ignored. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub properties: Vec, /// The id of its category ([`AnnotationCategory`]; `None`: uncategorised). An id the file /// doesn't define reads as uncategorised. #[serde(default, skip_serializing_if = "Option::is_none")] pub category: Option, } /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AnnotationCategory { pub id: String, pub name: String, pub color: Color, } impl AnnotationCategory { /// The colours offered for categories (the defaults take the first four). pub const COLORS: [Color; 8] = [ Color::from_rgb8_const(0x2F, 0x6F, 0xEB), Color::from_rgb8_const(0x8A, 0x4F, 0xE0), Color::from_rgb8_const(0x1E, 0x9E, 0x6A), Color::from_rgb8_const(0xD9, 0x7A, 0x06), Color::from_rgb8_const(0xD6, 0x3A, 0x3A), Color::from_rgb8_const(0xD1, 0x3F, 0x9B), Color::from_rgb8_const(0x11, 0x92, 0xA8), Color::from_rgb8_const(0x6B, 0x72, 0x80), ]; pub fn new(id: &str, name: &str, color: Color) -> Self { Self { id: id.to_string(), name: name.to_string(), color } } /// The categories a file starts with. pub fn defaults() -> Vec { let [dev, interaction, a11y, content, ..] = Self::COLORS; vec![ Self::new("development", "Development", dev), Self::new("interaction", "Interaction", interaction), Self::new("accessibility", "Accessibility", a11y), Self::new("content", "Content", content), ] } } /// A ruler guide on a page or frame. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// `true` for a vertical guide (x position), `false` for horizontal (y). pub vertical: bool, pub offset: f64, } impl NodeKind { pub fn type_name(&self) -> &'static str { match self { NodeKind::Page { .. } => "PAGE", NodeKind::Frame => "FRAME", NodeKind::Group => "GROUP", NodeKind::Section { .. } => "SECTION", NodeKind::Rectangle => "RECTANGLE", NodeKind::Ellipse { .. } => "ELLIPSE", NodeKind::Polygon { .. } => "POLYGON", NodeKind::Star { .. } => "STAR", NodeKind::Line => "LINE", NodeKind::Vector { .. } => "VECTOR", NodeKind::Text(_) => "TEXT", NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION", NodeKind::Component { .. } => "COMPONENT", NodeKind::ComponentSet { .. } => "COMPONENT_SET", NodeKind::Instance { .. } => "INSTANCE", NodeKind::Slice => "SLICE", } } /// The label the design panel's type dropdown shows. pub fn label(&self) -> &'static str { match self { NodeKind::Page { .. } => "Page", NodeKind::Frame => "Frame", NodeKind::Group => "Group", NodeKind::Section { .. } => "Section", NodeKind::Rectangle => "Rectangle", NodeKind::Ellipse { .. } => "Ellipse", NodeKind::Polygon { .. } => "Polygon", NodeKind::Star { .. } => "Star", NodeKind::Line => "Line", NodeKind::Vector { .. } => "Vector", NodeKind::Text(_) => "Text", NodeKind::BooleanOp { op } => op.label(), NodeKind::Component { .. } => "Component", NodeKind::ComponentSet { .. } => "Component set", NodeKind::Instance { .. } => "Instance", NodeKind::Slice => "Slice", } } /// Frame-like nodes: can have auto layout, clip content, layout grids, and act as artboards. pub fn is_frame_like(&self) -> bool { matches!(self, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }) } /// Nodes that may contain children. pub fn is_container(&self) -> bool { matches!( self, NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::Section { .. } | NodeKind::BooleanOp { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } ) } /// Group-like containers whose bounds follow their children. pub fn bounds_follow_children(&self) -> bool { matches!(self, NodeKind::Group | NodeKind::BooleanOp { .. }) } /// Shapes with a geometric outline of their own. pub fn is_shape(&self) -> bool { matches!( self, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Line | NodeKind::Vector { .. } ) } /// Kinds whose corners can be rounded from the panel. pub fn has_corner_radius(&self) -> bool { matches!( self, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::Section { .. } ) } pub fn text(&self) -> Option<&TextData> { match self { NodeKind::Text(t) => Some(t), _ => None, } } pub fn text_mut(&mut self) -> Option<&mut TextData> { match self { NodeKind::Text(t) => Some(t), _ => None, } } } /// One node of the scene graph. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Node { pub id: NodeId, pub name: String, #[serde(flatten)] pub kind: NodeKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, #[serde(default, skip_serializing_if = "is_default")] pub children: Vec, #[serde(default = "yes", skip_serializing_if = "is_yes")] pub visible: bool, #[serde(default, skip_serializing_if = "is_default")] pub locked: bool, #[serde(default = "one", skip_serializing_if = "is_one")] pub opacity: f32, #[serde(default = "pass", skip_serializing_if = "is_pass")] pub blend: BlendMode, /// Transform relative to the parent (the page for top-level nodes). #[serde(default = "identity", skip_serializing_if = "is_identity")] pub transform: Affine, pub width: f64, pub height: f64, #[serde(default, skip_serializing_if = "is_default")] pub fills: Vec, #[serde(default, skip_serializing_if = "is_default")] pub strokes: Vec, #[serde(default, skip_serializing_if = "is_default")] pub stroke: StrokeProps, #[serde(default, skip_serializing_if = "is_default")] pub effects: Vec, #[serde(default, skip_serializing_if = "is_default")] pub corners: Corners, #[serde(default, skip_serializing_if = "is_default")] pub constraints: Constraints, /// Auto layout (frame-like nodes only). #[serde(default, skip_serializing_if = "is_default")] pub layout: Option, #[serde(default, skip_serializing_if = "is_default")] pub child_layout: ChildLayout, /// Clip content (frames). #[serde(default, skip_serializing_if = "is_default")] pub clip: bool, /// This node masks the siblings above it. #[serde(default, skip_serializing_if = "is_default")] pub is_mask: bool, /// How a mask masks: by its alpha, its outline, or its luminance. #[serde(default, skip_serializing_if = "MaskType::is_alpha")] pub mask_type: MaskType, #[serde(default, skip_serializing_if = "is_default")] pub grids: Vec, /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]). #[serde(default, skip_serializing_if = "is_default")] pub guides: Vec, #[serde(default, skip_serializing_if = "is_default")] pub exports: Vec, #[serde(default, skip_serializing_if = "is_default")] pub interactions: Vec, #[serde(default, skip_serializing_if = "is_default")] pub overflow: Overflow, #[serde(default, skip_serializing_if = "is_default")] pub scroll_position: ScrollPosition, #[serde(default, skip_serializing_if = "is_default")] pub styles: StyleRefs, /// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id. #[serde(default, skip_serializing_if = "is_default")] pub bound: BTreeMap, /// Explicit variable modes (collection → mode) set on this container. #[serde(default, skip_serializing_if = "is_default")] pub var_modes: BTreeMap, /// In a boolean group: how this operand combines with the operands below it (`None`: the /// group's operation). Ignored for the bottom operand and outside boolean groups. #[serde(default, skip_serializing_if = "Option::is_none")] pub bool_op: Option, /// Lock aspect ratio when resizing from the panel. #[serde(default, skip_serializing_if = "is_default")] pub constrain_proportions: bool, /// Inside an instance: the node of the main component this one mirrors. #[serde(default, skip_serializing_if = "is_default")] pub source: Option, /// A nested instance inside a main component whose properties show on the main's instances. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exposed: bool, /// Handoff status of a frame or section (shown as a badge next to its name). #[serde(default, skip_serializing_if = "DevStatus::is_none")] pub dev_status: DevStatus, /// Ready for dev, but its contents changed since it was marked (shown as "Changed" until the /// change is resolved or the layer is marked again). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub dev_changed: bool, /// Why the last change to a ready-for-dev layer was made (entered when resolving it). #[serde(default, skip_serializing_if = "String::is_empty")] pub dev_note: String, /// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot": /// this slot holds the instance's own content). #[serde(default, skip_serializing_if = "is_default")] pub overrides: BTreeSet, /// Component property references: which property controls this node's /// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot /// property this frame is ("slot"). #[serde(default, skip_serializing_if = "is_default")] pub prop_refs: BTreeMap, /// Free-form plugin / agent data. #[serde(default, skip_serializing_if = "is_default")] pub plugin_data: BTreeMap, /// Dev Mode annotations, in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub annotations: Vec, } pub(crate) fn yes() -> bool { true } fn one() -> f32 { 1.0 } fn pass() -> BlendMode { BlendMode::PassThrough } fn identity() -> Affine { Affine::IDENTITY } // Fields at their default value are left out of the JSON (the file is several times smaller and // faster to write and read); reading fills them back in from the same defaults. /// Whether `v` is its type's default (what `#[serde(default)]` reads back for a missing field). pub(crate) fn is_default(v: &T) -> bool { *v == T::default() } fn is_yes(v: &bool) -> bool { *v == yes() } fn is_one(v: &f32) -> bool { *v == one() } fn is_pass(v: &BlendMode) -> bool { *v == pass() } fn is_identity(v: &Affine) -> bool { *v == identity() } impl Node { /// A node with Figma's defaults for its kind. pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self { let fills = match &kind { NodeKind::Frame | NodeKind::Component { .. } => vec![Paint::solid(Color::WHITE)], NodeKind::Section { .. } => vec![Paint::solid(Color::WHITE)], NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } => { vec![Paint::solid(Color::DEFAULT_SHAPE)] } NodeKind::Text(_) => vec![Paint::solid(Color::BLACK)], _ => vec![], }; let strokes = match &kind { NodeKind::Line => vec![Paint::solid(Color::BLACK)], NodeKind::ComponentSet { .. } => vec![Paint::solid(Color::from_rgb8_const(0x97, 0x47, 0xFF))], _ => vec![], }; let stroke = match &kind { NodeKind::Line | NodeKind::Vector { .. } => StrokeProps { align: StrokeAlign::Center, ..Default::default() }, NodeKind::ComponentSet { .. } => StrokeProps { dashes: vec![10.0, 5.0], ..Default::default() }, _ => StrokeProps::default(), }; let blend = if kind.is_container() { BlendMode::PassThrough } else { BlendMode::Normal }; let clip = matches!(kind, NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); let corners = match &kind { NodeKind::ComponentSet { .. } => Corners { radii: [5.0; 4], smoothing: 0.0 }, _ => Corners::default(), }; Self { id, name: name.into(), kind, parent: None, children: Vec::new(), visible: true, locked: false, opacity: 1.0, blend, transform: Affine::IDENTITY, width: width.max(0.0), height: height.max(0.0), fills, strokes, stroke, effects: Vec::new(), corners, constraints: Constraints::default(), layout: None, child_layout: ChildLayout::default(), clip, is_mask: false, mask_type: MaskType::Alpha, grids: Vec::new(), guides: Vec::new(), exports: Vec::new(), interactions: Vec::new(), overflow: Default::default(), scroll_position: Default::default(), styles: StyleRefs::default(), bound: BTreeMap::new(), var_modes: BTreeMap::new(), bool_op: None, constrain_proportions: false, source: None, exposed: false, dev_status: DevStatus::None, dev_changed: false, dev_note: String::new(), overrides: BTreeSet::new(), prop_refs: BTreeMap::new(), plugin_data: BTreeMap::new(), annotations: Vec::new(), } } /// A new section with the look sections are created with (white, a light grey border, slightly /// rounded, contents unclipped). pub fn new_section(id: NodeId, name: impl Into, width: f64, height: f64) -> Self { let mut n = Self::new(id, NodeKind::Section { contents_hidden: false }, name, width, height); n.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))]; n.corners.radii = [2.0; 4]; n.clip = false; n } pub fn size(&self) -> (f64, f64) { (self.width, self.height) } pub fn local_rect(&self) -> figcraft_geom::Rect { figcraft_geom::Rect::new(0.0, 0.0, self.width, self.height) } pub fn is_page(&self) -> bool { matches!(self.kind, NodeKind::Page { .. }) } /// A section whose contents are hidden: only its name and outline show; its children are /// skipped when rendering and hit testing. pub fn hides_contents(&self) -> bool { matches!(self.kind, NodeKind::Section { contents_hidden: true }) } /// Whether `self` and `other` differ only in handoff status and whether a section's contents /// are hidden (neither counts as a change to the design). pub fn same_design(&self, other: &Node) -> bool { if self == other { return true; } let plain = |n: &Node| { let mut n = n.clone(); n.dev_status = DevStatus::None; n.dev_changed = false; n.dev_note.clear(); if let NodeKind::Section { contents_hidden } = &mut n.kind { *contents_hidden = false; } n }; plain(self) == plain(other) } /// The ruler guides on this page or frame, in its own coordinates. pub fn guides(&self) -> &[Guide] { match &self.kind { NodeKind::Page { guides, .. } => guides, _ => &self.guides, } } pub fn guides_mut(&mut self) -> &mut Vec { match &mut self.kind { NodeKind::Page { guides, .. } => guides, _ => &mut self.guides, } } /// Whether painted strokes are present. pub fn has_visible_stroke(&self) -> bool { self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0 } /// Maximum distance (in local units) painting can reach outside the node box. pub fn paint_reach(&self) -> f64 { let stroke = if self.has_visible_stroke() { let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight()); let base = match self.stroke.align { StrokeAlign::Inside => 0.0, StrokeAlign::Center => w / 2.0, StrokeAlign::Outside => w, }; // Arrowheads are sized by the weight, whatever a width profile does. let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w.max(self.stroke.weight) * 3.0 } else { w }; base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * self.stroke.miter_limit.clamp(1.0, 4.0) } else { 0.0 }; let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max); stroke + fx + self.spread_miter_reach() } /// How much further than its spread a drop shadow can reach at the outline's sharp corners: /// spread moves the edges out in parallel and they meet in a mitre (see /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread. fn spread_miter_reach(&self) -> f64 { if matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() { return 0.0; } self.effects .iter() .filter(|e| e.visible) .filter_map(|e| match &e.kind { EffectKind::DropShadow(s) if s.spread.is_finite() => Some(s.spread.max(0.0) * (SPREAD_MITER_LIMIT - 1.0)), _ => None, }) .fold(0.0, f64::max) } }