Merge main into #1006

This commit is contained in:
bflatastic
2026-10-24 12:53:50 +07:00
263 changed files with 11821 additions and 1375 deletions
+35 -25
View File
@@ -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();
+2 -1
View File
@@ -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 { .. });
+13 -1
View File
@@ -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;
+34 -19
View File
@@ -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()
}
+1
View File
@@ -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() {
+28 -9
View File
@@ -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;
}
+22 -6
View File
@@ -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)
}
+157
View File
@@ -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}))
}
+12 -5
View File
@@ -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;
}
}
+13 -3
View File
@@ -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)
}),
]
}