mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
Merge main into #1421
apply_order takes #1458's numbering; a $var wrapper ranks 7, after boundVariables.
This commit is contained in:
@@ -576,7 +576,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id, "link": link(id)}))
|
||||
}
|
||||
),
|
||||
spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| {
|
||||
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
|
||||
let id = req_comment("comment.reply", s, p)?;
|
||||
let who = author(s, p);
|
||||
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
|
||||
@@ -586,7 +586,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
c.touched_by(&who);
|
||||
Ok(json!({"reply": c.replies.len() - 1}))
|
||||
}),
|
||||
spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| {
|
||||
spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| {
|
||||
let id = req_comment("comment.edit", s, p)?;
|
||||
let t = text("comment.edit", p)?;
|
||||
let who = author(s, p);
|
||||
@@ -607,7 +607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.react",
|
||||
"React to comment",
|
||||
"{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
|
||||
"{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.react", s, p)?;
|
||||
@@ -629,7 +629,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"on": on}))
|
||||
}
|
||||
),
|
||||
spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {
|
||||
spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| {
|
||||
let id = req_comment("comment.resolve", s, p)?;
|
||||
let v = boolean(p, "resolved").unwrap_or(true);
|
||||
if let Some(c) = s.doc_mut()?.comment_mut(id) {
|
||||
@@ -640,7 +640,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.move",
|
||||
"Move comment",
|
||||
"{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
|
||||
"{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.move", s, p)?;
|
||||
@@ -655,24 +655,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}
|
||||
),
|
||||
spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| {
|
||||
let id = req_comment("comment.delete", s, p)?;
|
||||
let who = author(s, p);
|
||||
let doc = s.doc_mut()?;
|
||||
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
|
||||
match message_index("comment.delete", c, p)? {
|
||||
0 => {
|
||||
own("comment.delete", &c.first, &who, "delete this thread")?;
|
||||
doc.comments.retain(|c| c.id != id);
|
||||
}
|
||||
i => {
|
||||
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
|
||||
own("comment.delete", m, &who, "delete this reply")?;
|
||||
c.replies.remove(i - 1);
|
||||
spec!(
|
||||
"comment.delete",
|
||||
"Delete comment",
|
||||
"{id | link, reply? (index: delete one reply; omit to delete the thread), author?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.delete", s, p)?;
|
||||
let who = author(s, p);
|
||||
let doc = s.doc_mut()?;
|
||||
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
|
||||
match message_index("comment.delete", c, p)? {
|
||||
0 => {
|
||||
own("comment.delete", &c.first, &who, "delete this thread")?;
|
||||
doc.comments.retain(|c| c.id != id);
|
||||
}
|
||||
i => {
|
||||
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
|
||||
own("comment.delete", m, &who, "delete this reply")?;
|
||||
c.replies.remove(i - 1);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"comment.markRead",
|
||||
"Mark comment as read",
|
||||
@@ -711,7 +717,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.next",
|
||||
"Next comment",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
|
||||
false,
|
||||
|s, p| {
|
||||
let dir = num(p, "dir").unwrap_or(1.0);
|
||||
@@ -748,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({"zoom": v.zoom}))
|
||||
}),
|
||||
spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| {
|
||||
spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| {
|
||||
let id = req_comment("comment.link", s, p)?;
|
||||
Ok(json!({"link": link(id)}))
|
||||
}),
|
||||
|
||||
@@ -53,37 +53,61 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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)}",
|
||||
"{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? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (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)",
|
||||
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (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!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| {
|
||||
create_kind(s, p, "rectangle")
|
||||
}),
|
||||
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
|
||||
s, p, "ellipse"
|
||||
)),
|
||||
spec!(
|
||||
"shape.polygon",
|
||||
"Polygon",
|
||||
"{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "polygon")
|
||||
),
|
||||
spec!(
|
||||
"shape.star",
|
||||
"Star",
|
||||
"{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "star")
|
||||
),
|
||||
spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| {
|
||||
create_kind(s, p, "line")
|
||||
}),
|
||||
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
|
||||
s, p, "arrow"
|
||||
)),
|
||||
spec!(
|
||||
"text.create",
|
||||
"Text",
|
||||
"{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}",
|
||||
true,
|
||||
|s, p| { create_kind(s, p, "text") }
|
||||
),
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
|
||||
string, targets,
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string,
|
||||
req_targets, string, targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -29,6 +29,80 @@ use select::{
|
||||
select_parent, select_same, select_sibling, toggle,
|
||||
};
|
||||
|
||||
/// The layer types `node.find` takes, as `NodeKind::type_name` spells them.
|
||||
const LAYER_TYPES: [&str; 16] = [
|
||||
"PAGE",
|
||||
"FRAME",
|
||||
"GROUP",
|
||||
"SECTION",
|
||||
"RECTANGLE",
|
||||
"ELLIPSE",
|
||||
"POLYGON",
|
||||
"STAR",
|
||||
"LINE",
|
||||
"VECTOR",
|
||||
"TEXT",
|
||||
"BOOLEAN_OPERATION",
|
||||
"COMPONENT",
|
||||
"COMPONENT_SET",
|
||||
"INSTANCE",
|
||||
"SLICE",
|
||||
];
|
||||
|
||||
/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a
|
||||
/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`).
|
||||
fn fold(s: &str) -> String {
|
||||
s.to_lowercase().replace('ς', "σ")
|
||||
}
|
||||
|
||||
fn node_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.find";
|
||||
let filter = |key| -> Result<Option<String>> {
|
||||
get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
|
||||
};
|
||||
let name = filter("name")?;
|
||||
let text = filter("text")?;
|
||||
let component = filter("component")?;
|
||||
let kind = filter("type")?;
|
||||
if let Some(kind) = &kind
|
||||
&& !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind))
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", "))));
|
||||
}
|
||||
let limit = match get(p, "limit") {
|
||||
None => 100,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize,
|
||||
};
|
||||
let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?;
|
||||
let doc = s.doc()?;
|
||||
// Keep one child iterator per ancestor, not a materialised subtree or all pending siblings.
|
||||
let mut stack = vec![doc.get(root)?.children.iter()];
|
||||
let mut ids = Vec::new();
|
||||
let mut total = 0usize;
|
||||
while let Some(children) = stack.last_mut() {
|
||||
let Some(&id) = children.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let n = doc.get(id)?;
|
||||
stack.push(n.children.iter());
|
||||
if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q))
|
||||
|| kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q))
|
||||
|| text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q)))
|
||||
|| component.as_ref().is_some_and(
|
||||
|q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))),
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if ids.len() < limit {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"ids": ids, "total": total}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
|
||||
@@ -235,6 +309,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = req_id("node.get", p, "id")?;
|
||||
Ok(crate::inspect::node_json(s.doc()?, i, 1))
|
||||
}),
|
||||
spec!(
|
||||
"node.find",
|
||||
"Find layers",
|
||||
"{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select",
|
||||
false,
|
||||
node_find
|
||||
),
|
||||
spec!(
|
||||
"node.reparent",
|
||||
"Move layer into",
|
||||
@@ -248,8 +329,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc_mut()?.reorder(i, idx)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
|
||||
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
|
||||
spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
|
||||
spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
|
||||
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
@@ -273,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
|
||||
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes",
|
||||
true,
|
||||
pattern_fill
|
||||
),
|
||||
@@ -354,7 +435,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.update",
|
||||
"Edit export setting",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
@@ -387,6 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
let cmd =
|
||||
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||||
crate::batch::refuse_unknown_step_keys(i, c)?;
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
use open::{import, open, open_recent};
|
||||
use open::{import, open, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -121,13 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("format".into(), json!("svg"));
|
||||
spec!(
|
||||
"file.exportSvg",
|
||||
"Export selection as SVG",
|
||||
"{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("format".into(), json!("svg"));
|
||||
}
|
||||
export(s, &q)
|
||||
}
|
||||
export(s, &q)
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"file.exportFramesPdf",
|
||||
"Export frames to PDF",
|
||||
@@ -142,7 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
import
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!(
|
||||
"file.placeImage",
|
||||
"Place image",
|
||||
"{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}",
|
||||
true,
|
||||
super::create::place_image
|
||||
),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
@@ -191,21 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
slot.on_disk = on_disk;
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
revert(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
|
||||
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return open_data(s, &d, p, variables.as_ref());
|
||||
}
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
// Already open: switch to it.
|
||||
// Already open: switch to it, and say when the file changed on disk since this tab read or
|
||||
// saved it (`stale`), or read it again (`reload`).
|
||||
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
|
||||
s.active = i;
|
||||
remember(s, &path);
|
||||
return Ok(json!({"index": i}));
|
||||
let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
|
||||
if !boolean(p, "reload").unwrap_or(false) {
|
||||
return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
|
||||
}
|
||||
if s.ds()?.is_dirty() {
|
||||
return Err(EngineError::Disabled(
|
||||
"file.open".into(),
|
||||
"the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
|
||||
));
|
||||
}
|
||||
if s.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
|
||||
}
|
||||
revert(s)?;
|
||||
return Ok(json!({"index": i, "reloaded": true}));
|
||||
}
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
@@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
|
||||
pub(super) fn revert(s: &mut Session) -> Result<()> {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
slot.on_disk = on_disk;
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
|
||||
/// the format. It opens as a new document with no path, so the first save asks where to put it.
|
||||
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
|
||||
|
||||
@@ -405,23 +405,37 @@ pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Resul
|
||||
Ok(Some(name))
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
|
||||
/// links after the values so a link wins over values its style sets, variable bindings last so
|
||||
/// paints set in the same call don't detach them. `$var` wrappers also win over style links.
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
|
||||
/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
|
||||
/// layout), style links after the values so a link wins over values its style sets, variable
|
||||
/// bindings last so paints set in the same call don't detach them. `$var` wrappers also win over style links.
|
||||
fn apply_order(k: &str, v: &Value) -> u8 {
|
||||
if v.get("$var").is_some() {
|
||||
return 6;
|
||||
return 7;
|
||||
}
|
||||
match k {
|
||||
"path" | "width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
"layout" | "layoutMode" => 1,
|
||||
"rotation" => 3,
|
||||
"x" | "y" => 4,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
|
||||
"boundVariables" => 6,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
|
||||
/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
|
||||
fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()> {
|
||||
let off = ["layout", "layoutMode"]
|
||||
.into_iter()
|
||||
.find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
|
||||
let tuning: Vec<String> = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
|
||||
let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
|
||||
let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
|
||||
Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
@@ -1034,6 +1048,7 @@ fn apply_variable(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
layout_keys_beside_none(cmd, props)?;
|
||||
let mut unknown = Vec::new();
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, v)| apply_order(k, v));
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.add",
|
||||
"Add interaction",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.set",
|
||||
"Edit interaction",
|
||||
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
|
||||
"{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)",
|
||||
true,
|
||||
set
|
||||
),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
|
||||
VectorNetwork, WindingRule, proto,
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
|
||||
StrokeProps, VectorNetwork, WindingRule, proto,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", 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"))?;
|
||||
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let fallback_fills = first
|
||||
.as_ref()
|
||||
.filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
|
||||
.map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
|
||||
let output_fills = first
|
||||
.as_ref()
|
||||
.map(|node| {
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
|
||||
fallback_fills.clone().unwrap_or_default()
|
||||
} else {
|
||||
node.fills.clone()
|
||||
}
|
||||
};
|
||||
// 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 }),
|
||||
);
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut net = VectorNetwork::default();
|
||||
for id in &ids {
|
||||
collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
|
||||
}
|
||||
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.fills = output_fills;
|
||||
v.strokes = f.strokes.clone();
|
||||
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
|
||||
v.effects = f.effects.clone();
|
||||
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": vid}))
|
||||
}
|
||||
|
||||
fn collect_flatten_parts(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
parent_world: Affine,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
net: &mut VectorNetwork,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(bad("vector.flatten", "group nesting exceeds 64"));
|
||||
}
|
||||
let Some(node) = doc.node(id) else { return Ok(()) };
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
|
||||
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
|
||||
// visible descendants in stacking order. Child clipping remains the container's outline rule.
|
||||
if node.kind.is_frame_like()
|
||||
&& node.fills.iter().any(|paint| paint.visible)
|
||||
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
|
||||
{
|
||||
let frame = VectorNetwork::from_path(&path);
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
|
||||
}
|
||||
for child in &node.children {
|
||||
if !doc.node(*child).is_some_and(|child| child.visible) {
|
||||
continue;
|
||||
}
|
||||
collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let part = match &node.kind {
|
||||
NodeKind::Vector { network } => {
|
||||
let mut part = network.clone();
|
||||
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
|
||||
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
|
||||
part.regions = vec![Region { loops, winding, ..Default::default() }];
|
||||
}
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
}
|
||||
};
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_flatten_part(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
into_parent: Affine,
|
||||
mut part: VectorNetwork,
|
||||
net: &mut VectorNetwork,
|
||||
) {
|
||||
for region in &mut part.regions {
|
||||
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
|
||||
if region.fills.is_none() {
|
||||
let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
|
||||
if effective != output_fills {
|
||||
region.fills = Some(effective.to_vec());
|
||||
}
|
||||
}
|
||||
region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
|
||||
}
|
||||
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
|
||||
..region
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.fills = n.strokes.clone();
|
||||
v.strokes.clear();
|
||||
v.styles.fill = n.styles.stroke;
|
||||
if n.fills.iter().any(|f| f.visible) && closed {
|
||||
let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
|
||||
if (n.fills.iter().any(|f| f.visible) || region_fill) && 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;
|
||||
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 {
|
||||
let source = network.clone();
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if legacy && !source.regions.is_empty() {
|
||||
let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = source.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
Err(_) => (path, winding),
|
||||
};
|
||||
let combined = Region { winding: output_winding, ..Default::default() };
|
||||
append_region_path(&mut net, &path, &combined, output_winding);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
}
|
||||
}
|
||||
if !source.regions.is_empty() {
|
||||
let mut used = vec![false; source.segments.len()];
|
||||
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
|
||||
if let Some(used) = used.get_mut(*segment) {
|
||||
*used = true;
|
||||
}
|
||||
}
|
||||
let mut open = source.clone();
|
||||
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
}
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
*network = net;
|
||||
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
if d == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let NodeKind::Vector { network: source } = &n.kind else {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut result = result;
|
||||
let bounds = result.bounding_box();
|
||||
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
|
||||
let node = doc.get_mut(i)?;
|
||||
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
|
||||
node.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
node.width = bounds.width();
|
||||
node.height = bounds.height();
|
||||
continue;
|
||||
};
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if source.regions.is_empty() {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let mut res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
nm.height = bb.height();
|
||||
continue;
|
||||
}
|
||||
if legacy {
|
||||
let path = source.fill_path();
|
||||
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut part = VectorNetwork::from_path(&result);
|
||||
let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
|
||||
for region in &mut part.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = source.regions[0].fills.clone();
|
||||
region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
append_network(&mut net, part);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let mut paint_region = region.clone();
|
||||
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
|
||||
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
// A collapsed negative offset removes this region, while other source regions stay.
|
||||
if res.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
|
||||
}
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.width = 0.0;
|
||||
nm.height = 0.0;
|
||||
continue;
|
||||
};
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
|
||||
let mut part = VectorNetwork::from_path(path);
|
||||
for region in &mut part.regions {
|
||||
region.winding = winding;
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
}
|
||||
append_network(net, part);
|
||||
}
|
||||
|
||||
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 });
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.create",
|
||||
"Create variable",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
|
||||
true,
|
||||
var_create
|
||||
),
|
||||
|
||||
@@ -279,13 +279,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
|
||||
style(s, p, |st| st.decoration = d)
|
||||
}),
|
||||
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
let l: ListStyle = get(p, "style")
|
||||
.or_else(|| get(p, "list"))
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
||||
set_list(s, p, l, false)
|
||||
}),
|
||||
spec!(
|
||||
"text.listStyle",
|
||||
"List style",
|
||||
"{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
|
||||
true,
|
||||
|s, p| {
|
||||
let l: ListStyle = get(p, "style")
|
||||
.or_else(|| get(p, "list"))
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
||||
set_list(s, p, l, false)
|
||||
}
|
||||
),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
@@ -374,7 +380,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
|
||||
Ok(json!({"ended": crate::textpreview::end(s)}))
|
||||
}),
|
||||
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
|
||||
spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -390,7 +396,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.style",
|
||||
"Style text range",
|
||||
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
|
||||
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
|
||||
@@ -373,7 +373,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
"{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
|
||||
Reference in New Issue
Block a user