Files
figcraft/crates/engine/src/cmd/create.rs
T

414 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Creating nodes.
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::*;
use figcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
("Phone", "iPhone 16", 393.0, 852.0),
("Phone", "iPhone 16 Pro", 402.0, 874.0),
("Phone", "iPhone 16 Pro Max", 440.0, 956.0),
("Phone", "iPhone 16 Plus", 430.0, 932.0),
("Phone", "iPhone SE", 320.0, 568.0),
("Phone", "Android Compact", 412.0, 917.0),
("Phone", "Android Medium", 700.0, 840.0),
("Tablet", "iPad mini 8.3", 744.0, 1133.0),
("Tablet", "iPad Pro 11\"", 834.0, 1194.0),
("Tablet", "iPad Pro 12.9\"", 1024.0, 1366.0),
("Tablet", "Surface Pro 8", 1440.0, 960.0),
("Tablet", "Android Expanded", 1280.0, 800.0),
("Desktop", "MacBook Air", 1280.0, 832.0),
("Desktop", "MacBook Pro 14\"", 1512.0, 982.0),
("Desktop", "MacBook Pro 16\"", 1728.0, 1117.0),
("Desktop", "Desktop", 1440.0, 1024.0),
("Desktop", "Wireframe", 1440.0, 1024.0),
("Desktop", "TV", 1280.0, 720.0),
("Presentation", "Slide 16:9", 1920.0, 1080.0),
("Presentation", "Slide 4:3", 1024.0, 768.0),
("Watch", "Apple Watch Series 10 42mm", 187.0, 223.0),
("Watch", "Apple Watch 41mm", 176.0, 215.0),
("Watch", "Apple Watch 45mm", 198.0, 242.0),
("Paper", "A4", 595.0, 842.0),
("Paper", "A5", 420.0, 595.0),
("Paper", "A6", 297.0, 420.0),
("Paper", "Letter", 612.0, 792.0),
("Paper", "Tabloid", 792.0, 1224.0),
("Social media", "Instagram post", 1080.0, 1350.0),
("Social media", "Instagram story", 1080.0, 1920.0),
("Social media", "Facebook post", 1200.0, 630.0),
("Social media", "X post", 1200.0, 675.0),
("Social media", "LinkedIn post", 1200.0, 1200.0),
("Social media", "YouTube thumbnail", 1280.0, 720.0),
("Social media", "Dribbble shot", 1600.0, 1200.0),
];
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"node.create",
"Create layer",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
true,
create
),
spec!(
"frame.create",
"Create frame",
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)",
true,
frame_create
),
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
}),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
spec!("frame.swapOrientation", "Swap orientation", "{ids?} (portrait ⇄ landscape: width and height trade places)", true, |s, p| {
let ids = req_targets("frame.swapOrientation", s, p)?;
let doc = s.doc()?;
let sizes: Vec<(NodeId, f64, f64)> = ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, n.width, n.height))).collect();
for (i, w, h) in sizes {
super::arrange::resize_cmd(s, &json!({"ids": [i], "width": h, "height": w}))?;
}
ok()
}),
spec!("frame.presets", "List frame presets", "{}", false, |_, _| {
Ok(Value::Array(FRAME_PRESETS.iter().map(|(c, n, w, h)| json!({"category": c, "name": n, "width": w, "height": h})).collect()))
}),
]
}
/// The top-most frame-like (or section) node on the page containing page point `pt`,
/// excluding `exclude` and their descendants.
pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> Option<NodeId> {
/// `locked`: inside an instance, where only slots (and what's in them) take layers.
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize, locked: bool) -> Option<NodeId> {
if depth > 128 || exclude.contains(&id) {
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
return None;
}
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
let inside = doc.world_bounds(id).is_some_and(|b| b.contains(pt)) && {
let inv = doc.world_transform(id).inverse();
n.local_rect().contains(inv * pt)
};
if is_container && !inside {
return None;
}
let locked = crate::components::locks_children(doc, id, locked);
for c in n.children.iter().rev() {
if let Some(f) = rec(doc, *c, pt, exclude, depth + 1, locked) {
return Some(f);
}
}
(is_container && inside && !locked).then_some(id)
}
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0, false))
}
pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
Some(match t.to_ascii_lowercase().as_str() {
"rectangle" | "rect" => (NodeKind::Rectangle, "Rectangle"),
"ellipse" | "circle" | "oval" => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"),
"polygon" | "triangle" => (NodeKind::Polygon { count: 3 }, "Polygon"),
"star" => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"),
"line" => (NodeKind::Line, "Line"),
"arrow" => (NodeKind::Line, "Arrow"),
"vector" | "path" => (NodeKind::Vector { network: VectorNetwork::default() }, "Vector"),
"text" => (NodeKind::Text(TextData::default()), "Text"),
"frame" => (NodeKind::Frame, "Frame"),
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
"group" => (NodeKind::Group, "Group"),
"slice" => (NodeKind::Slice, "Slice"),
_ => return None,
})
}
fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.insert("type".into(), json!(t));
}
create(s, &p)
}
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
/// or less on an axis it has is refused rather than flattening it.
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
None => Ok(1.0),
Some(a) if have <= 1e-9 => {
if a == 0.0 {
Ok(1.0)
} else {
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
}
}
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
};
Ok(match (width, height) {
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
(Some(_), None) => {
let s = fit(width, bw, "width")?;
(s, s)
}
(None, Some(_)) => {
let s = fit(height, bh, "height")?;
(s, s)
}
(None, None) => (1.0, 1.0),
})
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
let page = s.page()?;
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc()?;
let pn = doc.get(parent)?;
if !pn.kind.is_container() {
return Err(bad(cmd, format!("{parent} can't contain layers")));
}
if base == "Section" && !doc.takes_sections(parent) {
return Err(bad(cmd, "sections go on the page or in other sections"));
}
// X and Y are in the frame's space: a group parent has none of its own.
let space = if pn.kind.bounds_follow_children() { doc.frame_relative(parent) } else { figcraft_geom::Affine::IDENTITY };
let x = num_or(p, "x", 0.0);
let y = num_or(p, "y", 0.0);
let (mut w, mut h) = match base {
"Line" | "Arrow" => (num_or(p, "width", 100.0), 0.0),
"Text" => (num_or(p, "width", 0.0), num_or(p, "height", 0.0)),
"Frame" | "Component" => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
// for, scale the geometry: both sides independently, one side proportionally.
let mut offset = (0.0, 0.0);
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
}
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
if w > 0.0 {
td.auto_resize = TextAutoResize::Height;
}
}
let name = string(p, "name").unwrap_or_else(|| {
if let NodeKind::Text(td) = &kind {
let first: String = td.characters.lines().next().unwrap_or("").chars().take(64).collect();
if !first.trim().is_empty() {
return first;
}
}
if matches!(base, "Frame" | "Section" | "Component" | "Group") {
s.doc().map(|d| d.next_name(base)).unwrap_or_else(|_| base.to_string())
} else {
base.to_string()
}
});
// In the app (it has a theme), text that names no fill is readable on its page: white on a
// dark one. Headless sessions make the same thing everywhere.
let asks_fill = get(p, "props").is_some_and(|o| ["fill", "fills", "fillStyle"].iter().any(|k| o.get(k).is_some()));
let app_text = s.ui_dark.is_some() && base == "Text" && !asks_fill;
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let nid = doc.alloc_id();
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) {
node.fills = fills;
}
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
if base == "Arrow" {
node.stroke.cap_end = StrokeCap::LineArrow;
}
if let NodeKind::Text(_) = node.kind
&& let Some((tw, th)) = figcraft_text::auto_size(&node)
{
node.width = tw;
node.height = th;
}
let index = crate::params::opt_index(cmd, p, "index")?;
doc.insert(node, parent, index)?;
if let Some(Value::Object(props)) = get(p, "props") {
super::props::apply(cmd, doc, nid, props)?;
}
if boolean(p, "select").unwrap_or(true) {
ds.selection = vec![nid];
}
Ok(json!({"id": nid}))
}
fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(name) = string(p, "preset") {
let (_, pname, w, h) = FRAME_PRESETS
.iter()
.find(|(_, n, _, _)| n.eq_ignore_ascii_case(&name))
.ok_or_else(|| bad("frame.create", format!("unknown preset `{name}`")))?;
if let Some(o) = q.as_object_mut() {
o.entry("width").or_insert(json!(w));
o.entry("height").or_insert(json!(h));
o.entry("name").or_insert(json!(pname));
}
// No position on the page: beside the right-most top-level layer, else in the view.
if num(p, "x").is_none() && num(p, "y").is_none() && id(p, "parent").is_none() {
let (x, y) = free_spot(s, *w, *h)?;
if let Some(o) = q.as_object_mut() {
o.insert("x".into(), json!(x));
o.insert("y".into(), json!(y));
}
}
}
if let Some(o) = q.as_object_mut() {
o.insert("type".into(), json!("frame"));
}
create(s, &q)
}
/// Where a new top-level frame of `w`×`h` goes: 100 to the right of the right-most top-level
/// layer (top-aligned with it), or centred in the view on an empty page.
fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
let ds = s.ds()?;
let doc = &ds.doc;
let right_most = doc
.node(ds.page)
.map(|pg| pg.children.iter().filter_map(|c| doc.world_bounds(*c)).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|b| b.x1.is_finite() && b.y0.is_finite())
.max_by(|a, b| a.x1.total_cmp(&b.x1));
Ok(match right_most {
Some(b) => ((b.x1 + 100.0).round(), b.y0.round()),
None => ((ds.view.cx - w / 2.0).round(), (ds.view.cy - h / 2.0).round()),
})
}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>> {
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.place";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
read_path(&path)?
} else {
return Err(bad(cmd, "pass `data` (base64) or `path`"));
};
let mode = match string(p, "mode").unwrap_or_default().to_ascii_lowercase().as_str() {
"fit" => ImageScaleMode::Fit,
"crop" => ImageScaleMode::Crop,
"tile" => ImageScaleMode::Tile,
_ => ImageScaleMode::Fill,
};
let page = s.page()?;
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
let (iw, ih) = (f64::from(iw), f64::from(ih));
// Fit large images into 1000 px like Figma does when placing.
let k = (1000.0 / iw.max(ih)).min(1.0);
let w = num(p, "width").unwrap_or(iw * k);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
let nid = doc.alloc_id();
let name = string(p, "name").unwrap_or_else(|| "Image".into());
let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
node.transform = figcraft_geom::Affine::translate((num_or(p, "x", 0.0), num_or(p, "y", 0.0)));
node.fills = vec![Paint::image(hash.clone(), mode)];
doc.insert(node, parent, None)?;
ds.selection = vec![nid];
let _ = ok;
Ok(json!({"id": nid, "image": hash, "width": w, "height": h, "imageProfile": profile}))
}