mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
Merge main into #1006
This commit is contained in:
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"component.resetOverride",
|
||||
"Reset override",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
@@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layers": pushed}))
|
||||
}
|
||||
|
||||
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.restoreMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
@@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The main comes back with its old id (other instances point to it) and its layers with the
|
||||
// ids their copies mirror, so every instance re-links by source.
|
||||
let sub = doc.subtree(i);
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
let map = restore_id_map(doc, i, component, &sub);
|
||||
let mut nodes = Vec::new();
|
||||
let mut nested = Vec::new();
|
||||
for d in &sub {
|
||||
let Some(mut n) = doc.node(*d).cloned() else { continue };
|
||||
if *d == i {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in &props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
let defs = restore_prop_defs(doc, &props);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs };
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
|
||||
@@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
if !n.visible || n.locked {
|
||||
// Board objects (stickies, shapes with text) never take layers in.
|
||||
if !n.visible || n.locked || n.is_board_atom() {
|
||||
return None;
|
||||
}
|
||||
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
|
||||
@@ -494,6 +494,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
// Board connectors copied with what they join join the copies.
|
||||
let map = crate::board::copy_map(doc, &ids, &out);
|
||||
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
@@ -675,6 +678,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
|
||||
all.extend(map);
|
||||
}
|
||||
// Board connectors pasted with what they join join the pasted copies.
|
||||
let originals: Vec<(NodeId, figcraft_doc::BoardItem)> =
|
||||
clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect();
|
||||
crate::board::rejoin(doc, &originals, &all, !copy_local);
|
||||
if let Some(src) = foreign {
|
||||
crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?;
|
||||
}
|
||||
@@ -691,7 +698,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first()
|
||||
.copied()
|
||||
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
.filter(|i| {
|
||||
sel.len() == 1
|
||||
&& doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom())
|
||||
})
|
||||
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
@@ -1365,10 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
|
||||
if c.a < 1.0 {
|
||||
slot.opacity = c.a;
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
slot.visible = v;
|
||||
|
||||
@@ -11,22 +11,34 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// A new file of `kind` in a tab of its own.
|
||||
fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {
|
||||
let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into());
|
||||
let mut doc = Document::new(name);
|
||||
doc.kind = kind;
|
||||
// A property of the page from here on: a later change of theme leaves it alone. Boards are
|
||||
// light whatever the theme.
|
||||
let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() };
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active, "kind": kind.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| {
|
||||
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
|
||||
let mut doc = Document::new(name);
|
||||
// A property of the page from here on: a later change of theme leaves it alone.
|
||||
let background = s.new_page_background();
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),
|
||||
spec!(
|
||||
"file.newBoard",
|
||||
"New board",
|
||||
"{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page",
|
||||
false,
|
||||
|s, p| new_file(s, p, FileKind::Board)
|
||||
),
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
@@ -366,7 +378,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
|
||||
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
|
||||
}
|
||||
|
||||
fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
pub(crate) fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
|
||||
}
|
||||
|
||||
@@ -476,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
@@ -1169,8 +1181,11 @@ fn export_path(name: &str) -> String {
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// `name` without path separators and characters file systems reject.
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
|
||||
/// control characters become `_`); everything else is kept, up to the first 200 characters.
|
||||
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
|
||||
/// the number and extension it adds).
|
||||
pub fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(crate::online::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
list.extend(crate::templates::specs());
|
||||
list.extend(crate::board::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
|
||||
fn paint_none(v: &Value) -> bool {
|
||||
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
@@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, true, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, false, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -533,8 +548,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
} else {
|
||||
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
|
||||
};
|
||||
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
|
||||
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
|
||||
}
|
||||
let n = doc.get_mut(id)?;
|
||||
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
n.dev_status = st;
|
||||
// Marking a layer (again) starts from its current contents.
|
||||
n.dev_changed = false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"dev.compare",
|
||||
"Compare changes",
|
||||
"{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
"{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
false,
|
||||
compare
|
||||
),
|
||||
@@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "dev.compare";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?;
|
||||
let doc = s.doc()?;
|
||||
let version = match get(p, "version") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(v)) => Some(v.clone()),
|
||||
Some(_) => return Err(bad(cmd, "version: a version id (version.list)")),
|
||||
};
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev {
|
||||
return Err(bad(cmd, "compare a frame or section marked ready for dev"));
|
||||
if !has_status(doc, id) {
|
||||
return Err(bad(cmd, "compare a top-level frame or a section"));
|
||||
}
|
||||
let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null);
|
||||
let (cmp, against) = match &version {
|
||||
Some(v) => {
|
||||
let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?;
|
||||
let before = crate::cmd::file::read_version(ver)?;
|
||||
(crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time}))
|
||||
}
|
||||
None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")),
|
||||
None => (crate::devcompare::compare(doc, id), Value::Null),
|
||||
};
|
||||
let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null);
|
||||
v["version"] = against;
|
||||
if boolean(p, "show") == Some(true) {
|
||||
s.request_ui("view.devCompare", json!({"id": id}));
|
||||
s.request_ui("view.devCompare", json!({"id": id, "version": version}));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"vector.fillRule",
|
||||
"Set fill rule",
|
||||
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
|
||||
true,
|
||||
fill_rule
|
||||
),
|
||||
spec!(
|
||||
"vector.reverseLoop",
|
||||
"Reverse loop",
|
||||
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
|
||||
true,
|
||||
reverse_loop
|
||||
),
|
||||
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
|
||||
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
|
||||
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
|
||||
@@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"ids": fitted}))
|
||||
}
|
||||
|
||||
/// The vector network of `id`, when it is a vector layer.
|
||||
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
|
||||
match &mut doc.get_mut(id).ok()?.kind {
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id a fill rule is written as.
|
||||
fn rule_id(rule: WindingRule) -> &'static str {
|
||||
match rule {
|
||||
WindingRule::NonZero => "nonZero",
|
||||
WindingRule::EvenOdd => "evenOdd",
|
||||
}
|
||||
}
|
||||
|
||||
/// `region` as an index: absent (or null) means every area.
|
||||
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
|
||||
match get(p, "region") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`region` out of range"));
|
||||
}
|
||||
Ok(Some(k as usize))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
|
||||
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
|
||||
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
|
||||
/// don't decide.
|
||||
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (r, region) in net.regions.iter().enumerate() {
|
||||
for (l, lp) in region.loops.iter().enumerate() {
|
||||
if (r, l) != skip && lp.len() <= 2 {
|
||||
out.extend(lp.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.fillRule", s, p)?;
|
||||
let rule = match string(p, "rule").as_deref().map(str::trim) {
|
||||
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
|
||||
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
|
||||
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
|
||||
};
|
||||
let only = area_index("vector.fillRule", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut count = 0usize;
|
||||
for id in ids {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
match only {
|
||||
Some(r) => {
|
||||
if let Some(region) = net.regions.get_mut(r) {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for region in &mut net.regions {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
|
||||
}
|
||||
Ok(json!({"regions": count, "rule": rule_id(rule)}))
|
||||
}
|
||||
|
||||
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
|
||||
/// for a loop of one or two segments, the one to store the other way.
|
||||
struct LoopFlip {
|
||||
id: NodeId,
|
||||
turned: Vec<usize>,
|
||||
store: Option<usize>,
|
||||
}
|
||||
|
||||
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
|
||||
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
|
||||
///
|
||||
/// How a loop is walked depends on how many segments it has:
|
||||
/// - three or more: the order they are listed in, each segment turned to continue from the point
|
||||
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
|
||||
/// segment's ends belong to its neighbour too);
|
||||
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
|
||||
/// - one: only its stored ends say which way it runs, so they are swapped.
|
||||
///
|
||||
/// A short loop can only turn round by re-storing a segment, which is refused when another short
|
||||
/// loop lists that segment too: it would turn that area round as well.
|
||||
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "vector.reverseLoop";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let region = req_index(cmd, p, "region")?;
|
||||
let index = req_index(cmd, p, "loop")?;
|
||||
let doc = s.doc_mut()?;
|
||||
// Work every loop out before touching the document: one that can't turn round refuses the call
|
||||
// and the others stay as they were.
|
||||
let mut flips: Vec<LoopFlip> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
|
||||
if loop_.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut store = None;
|
||||
if loop_.len() <= 2 {
|
||||
let busy = short_loop_segments(network, (region, index));
|
||||
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
|
||||
));
|
||||
};
|
||||
store = Some(pick);
|
||||
}
|
||||
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
|
||||
}
|
||||
if flips.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
|
||||
}
|
||||
for LoopFlip { id, turned, store } in flips {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
|
||||
std::mem::swap(&mut seg.a, &mut seg.b);
|
||||
std::mem::swap(&mut seg.ta, &mut seg.tb);
|
||||
}
|
||||
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
|
||||
*l = turned;
|
||||
}
|
||||
}
|
||||
Ok(json!({"reversed": true}))
|
||||
}
|
||||
|
||||
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
|
||||
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
|
||||
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
|
||||
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
@@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
if bind {
|
||||
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
|
||||
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
|
||||
p.color_alpha = 1.0;
|
||||
} else {
|
||||
p.opacity *= color.a;
|
||||
p.color_alpha *= color.a;
|
||||
}
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
@@ -465,11 +465,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"tool.set",
|
||||
"Choose tool",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
|
||||
false,
|
||||
|s, p| {
|
||||
let t = req_string("tool.set", p, "tool")?;
|
||||
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
|
||||
if t.on_boards() {
|
||||
crate::board::require(s, &format!("tool.{}", t.id()))?;
|
||||
}
|
||||
tool(s, t)
|
||||
}
|
||||
),
|
||||
@@ -534,7 +537,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
||||
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
||||
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
|
||||
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
|
||||
spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
|
||||
let r = tool(s, ToolId::Eyedropper)?;
|
||||
s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
|
||||
s.tool.picked = None;
|
||||
Ok(r)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user