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

356 lines
16 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::{EngineError, 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?, 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()
}
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 = id(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.
let mut offset = (0.0, 0.0);
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();
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
*network = VectorNetwork::from_path(&path);
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
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();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
offset = (bb.x0, bb.y0);
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = 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()
}
});
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) };
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 = num(p, "index").map(|i| i.max(0.0) as usize);
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>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>> {
Err(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 = id(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}))
}