Merge main into #1421

apply_order takes #1458's numbering; a $var wrapper ranks 7, after boundVariables.
This commit is contained in:
bflatastic
2026-10-29 13:46:39 +07:00
138 changed files with 4903 additions and 784 deletions
+29 -23
View File
@@ -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)}))
}),
+36 -12
View File
@@ -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
),
+88 -6
View File
@@ -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) });
}
+23 -25
View File
@@ -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!(
+37 -2
View File
@@ -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> {
+24 -9
View File
@@ -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));
+2 -2
View File
@@ -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
),
+275 -65
View File
@@ -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();
+1 -1
View File
@@ -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
),
+15 -9
View File
@@ -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)?;
+1 -1
View File
@@ -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") {