mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
889 lines
40 KiB
Rust
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}))
|
|
}
|