Files
figcraft/crates/doc/src/node.rs
T
BFlatandClaude Opus 5.5 ff0f5a40d7 M4: expose nested instances' properties on instances (#22)
- A nested instance inside a main component can be exposed (`component.exposeInstance`, an
  "Expose properties to instances" toggle in its panel); the main's instances then list it by
  name with its variant and component property controls, recursively for exposed instances
  inside it. Values set there are nested overrides and survive main edits.
- `instance.exposed {id}` lists an instance's exposed nested copies (for the CLI and MCP).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 04:08:48 +07:00

477 lines
15 KiB
Rust

//! 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 },
InstanceSwap { default: NodeId },
Variant { options: Vec<String>, default: String },
}
/// 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<crate::proto::Flow>,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
#[serde(default)]
guides: Vec<Guide>,
},
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<String, PropDef>,
},
ComponentSet {
#[serde(default)]
description: String,
},
Instance {
component: NodeId,
/// Values for the component's properties.
#[serde(default)]
props: BTreeMap<String, serde_json::Value>,
},
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",
}
}
}
/// 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)]
pub parent: Option<NodeId>,
#[serde(default)]
pub children: Vec<NodeId>,
#[serde(default = "yes")]
pub visible: bool,
#[serde(default)]
pub locked: bool,
#[serde(default = "one")]
pub opacity: f32,
#[serde(default = "pass")]
pub blend: BlendMode,
/// Transform relative to the parent (the page for top-level nodes).
#[serde(default = "identity")]
pub transform: Affine,
pub width: f64,
pub height: f64,
#[serde(default)]
pub fills: Vec<Paint>,
#[serde(default)]
pub strokes: Vec<Paint>,
#[serde(default)]
pub stroke: StrokeProps,
#[serde(default)]
pub effects: Vec<Effect>,
#[serde(default)]
pub corners: Corners,
#[serde(default)]
pub constraints: Constraints,
/// Auto layout (frame-like nodes only).
#[serde(default)]
pub layout: Option<AutoLayout>,
#[serde(default)]
pub child_layout: ChildLayout,
/// Clip content (frames).
#[serde(default)]
pub clip: bool,
/// This node masks the siblings above it.
#[serde(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)]
pub grids: Vec<LayoutGrid>,
#[serde(default)]
pub exports: Vec<ExportSetting>,
#[serde(default)]
pub interactions: Vec<Interaction>,
#[serde(default)]
pub overflow: Overflow,
#[serde(default)]
pub scroll_position: ScrollPosition,
#[serde(default)]
pub styles: StyleRefs,
/// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id.
#[serde(default)]
pub bound: BTreeMap<String, VariableId>,
/// Explicit variable modes (collection → mode) set on this container.
#[serde(default)]
pub var_modes: BTreeMap<CollectionId, ModeId>,
/// Lock aspect ratio when resizing from the panel.
#[serde(default)]
pub constrain_proportions: bool,
/// Inside an instance: the node of the main component this one mirrors.
#[serde(default)]
pub source: Option<NodeId>,
/// 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,
/// Inside an instance: property groups overridden locally ("fills", "characters", …).
#[serde(default)]
pub overrides: BTreeSet<String>,
/// Component property references: which property controls this node's
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap).
#[serde(default)]
pub prop_refs: BTreeMap<String, String>,
/// Free-form plugin / agent data.
#[serde(default)]
pub plugin_data: BTreeMap<String, String>,
}
fn yes() -> bool {
true
}
fn one() -> f32 {
1.0
}
fn pass() -> BlendMode {
BlendMode::PassThrough
}
fn identity() -> Affine {
Affine::IDENTITY
}
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, 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(),
exports: Vec::new(),
interactions: Vec::new(),
overflow: Default::default(),
scroll_position: Default::default(),
styles: StyleRefs::default(),
bound: BTreeMap::new(),
var_modes: BTreeMap::new(),
constrain_proportions: false,
source: None,
exposed: false,
overrides: BTreeSet::new(),
prop_refs: BTreeMap::new(),
plugin_data: BTreeMap::new(),
}
}
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 { .. })
}
/// 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(self.stroke.weight);
let base = match self.stroke.align {
StrokeAlign::Inside => 0.0,
StrokeAlign::Center => w / 2.0,
StrokeAlign::Outside => w,
};
let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w * 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
}
}