Files
figcraft/crates/engine/src/cmd/create.rs
T
bflatastic e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

**Fixes (the suite failed on 7 tests before them)**
- **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did.
- **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`).
- **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be.
- **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step.
- **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor.
- **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back).
- **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too.

Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports.

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00

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}))
}