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

889 lines
40 KiB
Rust

//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
/// add/remove, move to page and vector path edits.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!(
"object.convertToGroup",
"Convert to group",
"{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)",
true,
|s, p| convert(s, p, "object.convertToGroup", false)
),
spec!(
"object.convertToFrame",
"Convert to frame",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
true,
|s, p| convert(s, p, "object.convertToFrame", true)
),
spec!(
"object.convertToSection",
"Convert to section",
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
true,
convert_to_section
),
spec!(
"object.wrapInSection",
"Wrap in new section",
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
true,
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
),
spec!(
"object.resizeToFit",
"Resize to fit",
"{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)",
true,
resize_to_fit
),
spec!("object.mask", "Use as mask", "{ids?}", true, mask),
spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| {
for i in req_targets("object.releaseMask", s, p)? {
s.doc_mut()?.get_mut(i)?.is_mask = false;
}
ok()
}),
spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)),
spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)),
spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)),
spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)),
spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten),
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| {
for i in req_targets("layout.remove", s, p)? {
// Hugging needs auto layout and the children that filled have nothing to fill: both
// become fixed (the frame keeps filling its own auto layout parent).
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
n.layout = None;
super::props::drop_layout_bindings(n);
figcraft_layout::coherent_sizing(doc, i);
}
ok()
}),
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
let doc = s.doc_mut()?;
for n in doc.ids().collect::<Vec<_>>() {
if let Some(node) = doc.node(n)
&& node.plugin_data.contains_key("figcraft.thumbnail")
{
doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail");
}
}
doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into());
ok()
}),
spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize),
spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| {
for i in req_targets("vector.reverse", s, p)? {
if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind {
for seg in &mut network.segments {
std::mem::swap(&mut seg.a, &mut seg.b);
std::mem::swap(&mut seg.ta, &mut seg.tb);
}
}
}
ok()
}),
spec!(
"vector.fillRule",
"Set fill rule",
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
true,
fill_rule
),
spec!(
"vector.reverseLoop",
"Reverse loop",
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
true,
reverse_loop
),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p))
}),
spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| {
crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p))
}),
]
}
enum Wrap {
Group,
Frame,
Section,
Bool(BoolOp),
}
/// Room a section made around layers leaves on each side.
const SECTION_PAD: f64 = 40.0;
/// What the boolean commands do with what they're given.
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
/// Wrap the selection in a new boolean group doing `op`.
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
wrap(s, p, cmd, Wrap::Bool(op))
}
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
/// the command that asked.
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
// Sections sit on the page or in sections, never in frames or groups.
let board = |doc: &Document, i: NodeId| {
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
};
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
}
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
return Err(bad(cmd, "sections can't go in groups or frames"));
}
let index = doc.index_in_parent(top).map(|i| i + 1);
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
if matches!(w, Wrap::Section) {
bounds = bounds.inflate(SECTION_PAD, SECTION_PAD);
}
let pw = doc.world_transform(parent);
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds);
let (kind, base) = match &w {
Wrap::Group => (NodeKind::Group, "Group"),
Wrap::Frame => (NodeKind::Frame, "Frame"),
Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"),
Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()),
};
let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) });
let gid = doc.alloc_id();
let mut g = match w {
Wrap::Section => Node::new_section(gid, name, local.width(), local.height()),
_ => Node::new(gid, kind, name, local.width(), local.height()),
};
g.transform = Affine::translate((local.x0, local.y0));
if let Wrap::Bool(op) = w {
// A boolean group looks like its top layer; a subtraction like the bottom one it cuts.
let source = if op == BoolOp::Subtract { ids.first() } else { ids.last() };
if let Some(first) = source.and_then(|i| doc.node(*i)) {
g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() };
g.strokes = first.strokes.clone();
g.stroke = first.stroke.clone();
g.effects = first.effects.clone();
}
g.blend = figcraft_color::BlendMode::Normal;
}
if let Wrap::Frame = w {
g.clip = true;
}
// Insert the container where the topmost layer is.
doc.insert(g, parent, index)?;
for i in &ids {
doc.reparent(*i, gid, None)?;
}
if !matches!(w, Wrap::Frame | Wrap::Section) {
let _ = doc.fit_to_children(gid);
}
s.set_selection(vec![gid]);
Ok(json!({"id": gid}))
}
/// Turn frames into groups or groups into frames in place (same id, name, layers, effects,
/// opacity, exports and interactions), the layers inside staying where they are on the page.
/// Nothing changes unless every layer can convert.
fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
match (&n.kind, to_frame) {
// A frame can't hold sections.
(NodeKind::Section { .. }, true)
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
{
return refuse("holds sections, which can't go inside a frame: convert those first");
}
(NodeKind::Section { .. }, true) => todo.push(i),
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
return refuse("is inside an instance: edit its main component, or detach the instance");
}
(NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"),
(NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i),
(NodeKind::Frame | NodeKind::Group, _) => {}
_ => return refuse("is neither a frame nor a group"),
}
}
for i in &todo {
if to_frame {
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
// section keeps its fill.
let n = doc.get_mut(*i)?;
if matches!(n.kind, NodeKind::Group) {
n.fills.clear();
n.strokes.clear();
}
n.kind = NodeKind::Frame;
n.clip = false;
continue;
}
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Group;
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.fills.clear();
n.strokes.clear();
n.grids.clear();
n.guides.clear();
n.corners = Corners::default();
(n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None);
// Variables bound to what went would bring it back.
n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible"));
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
doc.fit_to_children(*i)?;
}
Ok(json!({"ids": todo}))
}
/// Frames on the page or in a section become sections in place (sections only sit there).
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.convertToSection";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
match &n.kind {
NodeKind::Section { .. } => {}
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
NodeKind::Frame => todo.push(i),
_ => return refuse("is not a frame"),
}
}
for i in &todo {
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Section { contents_hidden: false };
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
super::props::drop_layout_bindings(n);
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
}
Ok(json!({"ids": todo}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for g in ids {
let n = doc.get(g)?;
if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) {
continue;
}
let Some(parent) = n.parent else { continue };
let idx = doc.index_in_parent(g).unwrap_or(0);
let kids = n.children.clone();
// Ungrouping a group keeps its opacity on the children (multiplied).
let op = n.opacity;
for (k, c) in kids.iter().enumerate() {
doc.reparent(*c, parent, Some(idx + k))?;
// Released operands no longer combine with anything.
if doc.get(*c)?.bool_op.is_some() {
doc.get_mut(*c)?.bool_op = None;
}
if op < 1.0 {
let cn = doc.get_mut(*c)?;
cn.opacity *= op;
}
out.push(*c);
}
doc.remove(g)?;
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn mask(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.mask", s, p)?;
if ids.len() == 1 {
let doc = s.doc_mut()?;
let id = ids.first().copied().unwrap_or(NodeId(0));
let n = doc.get_mut(id)?;
n.is_mask = !n.is_mask;
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
doc.get_mut(first)?.is_mask = true;
}
Ok(r)
}
/// The outline of a node in its parent's space (shapes, text, booleans, groups).
pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option<BezPath> {
let n = doc.node(id)?;
let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?;
p.apply_affine(n.transform);
Some(p)
}
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.flatten", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut net = VectorNetwork::default();
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
// booleans flatten to their result; strokes don't get outlined.
let mut part = match &n.kind {
NodeKind::Vector { network } => network.clone(),
_ => {
let Some((op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
figcraft_render::subtree_outline(doc, n, 0)
}
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
let mut part = VectorNetwork::from_path(&op);
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut part.regions {
r.winding = WindingRule::EvenOdd;
}
}
part
}
};
// Into the common parent's space.
let all: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
}
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
};
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
vec![Paint::solid(Color::DEFAULT_SHAPE)]
} else {
f.fills.clone()
};
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
v.opacity = f.opacity;
v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend };
}
doc.insert(v, parent, index)?;
for i in &ids {
doc.remove(*i)?;
}
s.set_selection(vec![vid]);
Ok(json!({"id": vid}))
}
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let mut out = Vec::new();
for i in ids {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
if !n.has_visible_stroke() {
continue;
}
let closed = figcraft_doc::geometry::is_closed(&n);
let Some(geom) = (match &n.kind {
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
_ => figcraft_doc::geometry::stroke_geometry(&n),
}) else {
continue;
};
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
let mut path = (*area).clone();
// Union the pieces so the outline is one clean shape.
if let Ok(u) = figcraft_pathops::simplify(&path) {
path = u;
}
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?;
let idx = doc.index_in_parent(i);
let vid = doc.alloc_id();
let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height());
v.transform = n.transform * Affine::translate((bb.x0, bb.y0));
// The stroke's paints (and its colour style) fill the outline.
v.fills = n.strokes.clone();
v.strokes.clear();
v.styles.fill = n.styles.stroke;
if n.fills.iter().any(|f| f.visible) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
{
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
continue;
}
// Standing in for the layer, the outline keeps how it is drawn and placed.
(v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect);
(v.constraints, v.child_layout, v.visible, v.locked) = (n.constraints, n.child_layout.clone(), n.visible, n.locked);
doc.insert(v, parent, idx.map(|x| x + 1))?;
doc.remove(i)?;
out.push(vid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> =
f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min);
let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min);
let vertical = span_y > span_x;
let mut sorted = boxes.clone();
sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) });
let gaps: Vec<f64> =
sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect();
let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::<f64>() / gaps.len() as f64).max(0.0).round() };
let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default();
let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round);
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
let dir = string(p, "direction");
let doc = s.doc()?;
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
let targets = if all_frames {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
f.clip = false;
vec![fid]
};
let doc = s.doc_mut()?;
for f in &targets {
let mut al = infer_layout(doc, *f);
if let Some(d) = &dir {
al.mode = match d.to_ascii_lowercase().as_str() {
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
if !all_frames || !empty {
// A frame wrapped around the selection hugs it; an existing frame with no flow
// children keeps its size instead of collapsing to its padding.
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
}
}
s.set_selection(targets.clone());
Ok(json!({"ids": targets}))
}
fn move_to_page(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.moveToPage", s, p)?;
let page = req_id("object.moveToPage", p, "page")?;
let doc = s.doc_mut()?;
if !doc.node(page).is_some_and(Node::is_page) {
return Err(bad("object.moveToPage", format!("{page} is not a page")));
}
if doc.is_divider(page) {
return Err(bad("object.moveToPage", "a divider holds no layers"));
}
for i in z_sorted(doc, &roots(doc, &ids)) {
doc.reparent(i, page, None)?;
}
s.set_selection(vec![]);
ok()
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.rasterize", s, p)?;
let scale = num_or(p, "scale", 2.0);
let mut out = Vec::new();
for i in ids {
let doc = s.doc()?;
let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue };
let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?;
let b = figcraft_render::export_bounds(doc, i).unwrap_or_default();
let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?;
let idx = doc.index_in_parent(i);
let pw = doc.world_transform(parent);
let name = doc.get(i)?.name.clone();
let docm = s.doc_mut()?;
let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?;
let rid = docm.alloc_id();
let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height());
r.transform = pw.inverse() * Affine::translate((b.x0, b.y0));
r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
docm.insert(r, parent, idx.map(|x| x + 1))?;
docm.remove(i)?;
out.push(rid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let tol = num_or(p, "tolerance", 0.5).max(0.01);
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
let fill = network.fill_path();
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
let regions = !network.regions.is_empty();
let mut net = VectorNetwork::from_path(&simplified);
if !regions {
net.regions.clear();
}
*network = net;
}
}
ok()
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let d = req_num("vector.offset", p, "offset")?;
for i in req_targets("vector.offset", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
// A vector can't hold children: leave containers (frames, groups, instances) alone.
if !n.children.is_empty() {
continue;
}
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
};
let Ok(mut res) = res else { continue };
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
let mut net = VectorNetwork::from_path(&res);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
nm.height = bb.height();
}
ok()
}
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.join", s, p)?;
if ids.len() < 2 {
// Close a single open path.
if let Some(i) = ids.first() {
let n = s.doc_mut()?.get_mut(*i)?;
if let NodeKind::Vector { network } = &mut n.kind
&& network.regions.is_empty()
&& network.vertices.len() > 2
{
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec<usize> = (0..network.segments.len()).collect();
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
}
}
return ok();
}
flatten(s, &json!({"ids": ids}))
}
/// Fit containers to their visible children's bounds, keeping the children where they are on the
/// page (auto layout frames hug instead); rotated containers fit in their own axes.
fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.resizeToFit";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut fitted = Vec::new();
for id in ids {
let Some(n) = doc.node(id).cloned() else { continue };
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) {
continue;
}
// Auto layout frames fit by hugging their contents.
if n.layout.is_some() {
let node = doc.get_mut(id)?;
node.child_layout.sizing_h = Sizing::Hug;
node.child_layout.sizing_v = Sizing::Hug;
fitted.push(id);
continue;
}
// Children's bounds in the container's own coordinates.
let mut b: Option<figcraft_geom::Rect> = None;
for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) {
let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect());
if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() {
b = Some(b.map_or(r, |u| u.union(r)));
}
}
let Some(b) = b else { continue };
let (w, h) = (b.width().max(0.01), b.height().max(0.01));
// Move the container by the offset, and its children back by it.
let shift = Affine::translate((b.x0, b.y0));
let node = doc.get_mut(id)?;
node.transform *= shift;
node.width = w;
node.height = h;
for c in &n.children {
if let Ok(cn) = doc.get_mut(*c) {
cn.transform = shift.inverse() * cn.transform;
}
}
fitted.push(id);
}
Ok(json!({"ids": fitted}))
}
/// The vector network of `id`, when it is a vector layer.
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
match &mut doc.get_mut(id).ok()?.kind {
NodeKind::Vector { network } => Some(network),
_ => None,
}
}
/// The id a fill rule is written as.
fn rule_id(rule: WindingRule) -> &'static str {
match rule {
WindingRule::NonZero => "nonZero",
WindingRule::EvenOdd => "evenOdd",
}
}
/// `region` as an index: absent (or null) means every area.
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
match get(p, "region") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
return Err(bad(cmd, "`region` out of range"));
}
Ok(Some(k as usize))
}
}
}
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
/// don't decide.
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
let mut out = std::collections::HashSet::new();
for (r, region) in net.regions.iter().enumerate() {
for (l, lp) in region.loops.iter().enumerate() {
if (r, l) != skip && lp.len() <= 2 {
out.extend(lp.iter().copied());
}
}
}
out
}
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.fillRule", s, p)?;
let rule = match string(p, "rule").as_deref().map(str::trim) {
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
};
let only = area_index("vector.fillRule", p)?;
let doc = s.doc_mut()?;
let mut count = 0usize;
for id in ids {
let Some(net) = network_mut(doc, id) else { continue };
match only {
Some(r) => {
if let Some(region) = net.regions.get_mut(r) {
region.winding = rule;
count += 1;
}
}
None => {
for region in &mut net.regions {
region.winding = rule;
count += 1;
}
}
}
}
if count == 0 {
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
}
Ok(json!({"regions": count, "rule": rule_id(rule)}))
}
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
/// for a loop of one or two segments, the one to store the other way.
struct LoopFlip {
id: NodeId,
turned: Vec<usize>,
store: Option<usize>,
}
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
///
/// How a loop is walked depends on how many segments it has:
/// - three or more: the order they are listed in, each segment turned to continue from the point
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
/// segment's ends belong to its neighbour too);
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
/// - one: only its stored ends say which way it runs, so they are swapped.
///
/// A short loop can only turn round by re-storing a segment, which is refused when another short
/// loop lists that segment too: it would turn that area round as well.
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "vector.reverseLoop";
let ids = req_targets(cmd, s, p)?;
let region = req_index(cmd, p, "region")?;
let index = req_index(cmd, p, "loop")?;
let doc = s.doc_mut()?;
// Work every loop out before touching the document: one that can't turn round refuses the call
// and the others stay as they were.
let mut flips: Vec<LoopFlip> = Vec::new();
for id in ids {
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
if loop_.is_empty() {
continue;
}
let mut store = None;
if loop_.len() <= 2 {
let busy = short_loop_segments(network, (region, index));
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
return Err(EngineError::Disabled(
cmd.into(),
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
));
};
store = Some(pick);
}
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
}
if flips.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
}
for LoopFlip { id, turned, store } in flips {
let Some(net) = network_mut(doc, id) else { continue };
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
std::mem::swap(&mut seg.a, &mut seg.b);
std::mem::swap(&mut seg.ta, &mut seg.tb);
}
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
*l = turned;
}
}
Ok(json!({"reversed": true}))
}