From 48b3ea91f0d4f5ec13e841a5cf72e15c9e25a50d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:59:59 +0000 Subject: [PATCH] refactor(engine): move cmd/brushsym.rs items to cmd/brushsym/{brushes,symbols}.rs --- crates/engine/src/cmd/brushsym.rs | 1147 +-------------------- crates/engine/src/cmd/brushsym/brushes.rs | 447 ++++++++ crates/engine/src/cmd/brushsym/symbols.rs | 688 ++++++++++++ 3 files changed, 1149 insertions(+), 1133 deletions(-) create mode 100644 crates/engine/src/cmd/brushsym/brushes.rs create mode 100644 crates/engine/src/cmd/brushsym/symbols.rs diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index d485366ea..b677b3003 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -21,6 +21,20 @@ use super::swatch::{anchor, names_param}; use super::*; use crate::EngineError; +mod brushes; +mod symbols; + +pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; +use brushes::{ + brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, + brush_selection_art, brush_set_current, brush_unused, expand_brush, +}; +pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size}; +use symbols::{ + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place, + symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update, +}; + const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. @@ -266,96 +280,6 @@ fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`")) } -// ---------- brushes ---------- - -/// Brushable nodes (paths and compound paths) under `ids`. -pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(*id) else { continue }; - n.walk(&mut |c| { - let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); - let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); - if own && !in_compound && !out.contains(&c.id) { - out.push(c.id); - } - }); - } - out -} - -fn brush_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); - Ok(json!({ "brushes": list, "current": brush::current(d) })) -} - -fn brush_get(s: &mut Session, p: &Value) -> Result { - let name = name_param(p, "brush.get")?; - let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; - Ok(serde_json::to_value(b).unwrap_or_default()) -} - -/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). -fn brush_selection_art(s: &mut Session, p: &Value) -> Result { - let art = selection_art(s, p)?; - Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) -} - -/// The stroke paint a brush gives paths without one: the current stroke colour, else black. -pub(crate) fn stroke_paint(s: &Session) -> Paint { - if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } -} - -/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). -pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { - for id in ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { - n.appearance.set_stroke(stroke.clone()); - } - if let Some(st) = n.appearance.stroke_mut() { - if st.width <= 0.0 { - st.width = 1.0; - } - st.brush = Some(name.to_string()); - } - } -} - -fn brush_apply(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.apply"; - let name = name_param(p, C)?.to_string(); - if brush::find(&s.doc()?.doc, &name).is_none() { - return Err(bad(C, format!("no brush named `{name}`"))); - } - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let stroke = stroke_paint(s); - s.edit("Apply Brush", |d, _| { - d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); - apply_brush(d, &ids, &name, &stroke); - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn brush_remove(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - s.edit("Remove Brush Stroke", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in &mut n.appearance.items { - if let AppearanceItem::Stroke(st) = it { - st.brush = None; - } - } - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - /// Set a document preference outside the undo history. fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { let st = s.doc_mut()?; @@ -367,19 +291,6 @@ fn set_unknown(s: &mut Session, key: &str, v: Option) -> Result<()> { Ok(()) } -fn brush_set_current(s: &mut Session, p: &Value) -> Result { - match str_param(p, "name") { - Some(n) => { - if brush::find(&s.doc()?.doc, n).is_none() { - return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); - } - set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; - } - None => set_unknown(s, brush::CURRENT_KEY, None)?, - } - ok() -} - /// The selection as one art node (a group for several objects), ids kept. pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; @@ -391,1033 +302,3 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { _ => Some(Node::group(NodeId(0), nodes.into_iter().map(Arc::new).collect())), }) } - -/// The definition fields that `params` change field by field when the brush has them as objects: -/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only -/// `start` keeps `end`. -const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; - -fn merge(base: &mut Value, patch: &Value) { - if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { - for (k, v) in pt { - match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { - (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { - for (f, fv) in fields { - inner.insert(f.clone(), fv.clone()); - } - } - _ => { - b.insert(k.clone(), v.clone()); - } - } - } - } -} - -/// Sets brush `to` on the strokes in `items` that paint with brush `from`. -fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && st.brush.as_deref() == Some(from) - { - st.brush = to.map(str::to_string); - } - } -} - -/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. -fn paints_with(n: &Node, name: &str) -> bool { - let mut hit = false; - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); - }); - hit -} - -/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that -/// hold such a stroke, and those that hold them, are copied. -fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { - set_stroke_brush(&mut n.appearance.items, from, to); - if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) - && paints_with(&mask.art, from) - { - set_brush_in(Arc::make_mut(&mut mask.art), from, to); - } - for c in n.children_mut().into_iter().flatten() { - if paints_with(c, from) { - set_brush_in(Arc::make_mut(c), from, to); - } - } -} - -/// Returns the art a brush is made of ([`Brush::art`]) for editing. -fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { - use vectorcraft_brush::BrushKind; - match &mut b.kind { - BrushKind::Scatter(s) => vec![&mut s.art], - BrushKind::Art(a) => vec![&mut a.art], - BrushKind::Pattern(p) => { - std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() - } - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], - } -} - -/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art -/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern -/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. -fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { - let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); - let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); - for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { - if paints_with(n, from) { - set_brush_in(Arc::make_mut(n), from, to); - } - } - for g in &mut d.graphic_styles { - set_stroke_brush(&mut g.appearance.items, from, to); - } - for n in lib.iter_mut().flat_map(brush_art_mut) { - set_brush_in(n, from, to); - } -} - -fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { - s.edit(label, |d, _| { - for (from, to) in renames { - rename_brush_strokes(d, &mut lib, from, to.as_deref()); - if brush::current(d).as_deref() == Some(from.as_str()) { - match to { - Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), - None => d.unknown.remove(brush::CURRENT_KEY), - }; - } - } - brush::store(d, &lib); - Ok(()) - }) -} - -fn brush_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.new"; - let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; - let art_key = match ty { - "calligraphic" | "bristle" => None, - "scatter" | "art" => Some("art"), - "pattern" => Some("side"), - o => return Err(bad(C, format!("unknown brush type `{o}`"))), - }; - let mut lib = brush::library(&s.doc()?.doc); - let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); - let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); - let name = brush::unique_name(&lib, &base_name); - if ty == "bristle" { - bristle_name_fits(C, &name)?; - } - let mut v = json!({"name": name, "type": ty}); - if let Some(params) = p.get("params") { - merge(&mut v, params); - v["name"] = json!(name); - v["type"] = json!(ty); - } - if let Some(k) = art_key - && v.get(k).is_none() - { - let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { - return Err(bad(C, "the selected art has no width")); - } - v[k] = serde_json::to_value(&art).unwrap_or_default(); - } - lib.push(parse_brush(C, v)?); - save_library(s, "New Brush", lib, &[])?; - Ok(json!({ "name": name })) -} - -/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. -fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { - let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); - if count > max { - return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); - } - Ok(()) -} - -/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). -fn parse_brush(cmd: &str, v: Value) -> Result { - Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) -} - -fn brush_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.delete"; - let names = names_param(p, C)?; - let mut lib = brush::library(&s.doc()?.doc); - if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { - return Err(bad(C, format!("no brush named `{name}`"))); - } - lib.retain(|b| !names.contains(&b.name)); - let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); - save_library(s, "Delete Brush", lib, &renames)?; - ok() -} - -/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their -/// type in library order: before the first brush of that type at or after `to` that isn't moving, -/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` -/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. -fn brush_move(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.move"; - const WITHIN: &str = "brushes move only within their type"; - let names = names_param(p, C)?; - check_numbers(p, &["to"], C)?; - let to = match p.get("to").filter(|v| !v.is_null()) { - Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), - None => None, - }; - let old = brush::library(&s.doc()?.doc); - let mut lib = old.clone(); - // `names_param` refuses an empty list, so the loop sets `kind`. - let mut kind = ""; - for name in &names { - let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - if !kind.is_empty() && kind != b.kind.type_id() { - return Err(bad(C, WITHIN)); - } - kind = b.kind.type_id(); - } - if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { - return Err(bad(C, WITHIN)); - } - let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); - let before = anchor(&same, to, &names, |b| &b.name); - let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); - let mut items = vec![]; - for name in &names { - if let Some(i) = lib.iter().position(|b| &b.name == name) { - items.push(lib.remove(i)); - } - } - let at = match before { - Some(a) => lib.iter().position(|b| b.name == a), - None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), - }; - let at = at.unwrap_or(first).min(lib.len()); - lib.splice(at..at, items); - if lib != old { - save_library(s, "Move Brush", lib, &[])?; - } - Ok(json!({ "moved": names.len() })) -} - -/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given -/// without `names`, names the copy. -fn brush_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.duplicate"; - let names = names_param(p, C)?; - let before = brush::library(&s.doc()?.doc); - let mut lib = before.clone(); - let mut out = vec![]; - for name in &names { - let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); - let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); - b.name = brush::unique_name(&lib, &new_name); - out.push(b.name.clone()); - lib.push(b); - } - save_library(s, "Duplicate Brush", lib, &[])?; - Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) -} - -/// Adds the names of the brushes the strokes of `items` paint with to `out`. -fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { - for it in items { - if let AppearanceItem::Stroke(st) = it - && let Some(b) = &st.brush - { - out.push(b.clone()); - } - } -} - -/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to -/// `out`. -fn art_brushes(n: &Node, out: &mut Vec) { - vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); -} - -/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph -/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush -/// that a counted stroke paints with. -fn brush_unused(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let lib = brush::library(d); - let mut todo = vec![]; - let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); - trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); - d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); - let mut used = BTreeSet::new(); - while let Some(name) = todo.pop() { - if !used.contains(&name) { - lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); - used.insert(name); - } - } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) -} - -fn brush_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "brush.options"; - let name = name_param(p, C)?.to_string(); - let mut lib = brush::library(&s.doc()?.doc); - let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); - if let Some(params) = p.get("params") { - merge(&mut v, params); - } - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); - if let Some(nn) = &new_name { - if lib.iter().any(|b| &b.name == nn) { - return Err(bad(C, format!("a brush named `{nn}` exists"))); - } - // A `newName` that only trims the white space around the saved name is taken at any length. - if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { - bristle_name_fits(C, nn)?; - } - v["name"] = json!(nn); - } else { - v["name"] = json!(name); - } - v["type"] = json!(lib[i].kind.type_id()); - lib[i] = parse_brush(C, v)?; - let out = lib[i].name.clone(); - let rename = new_name.map(|nn| (name, Some(nn))); - save_library(s, "Brush Options", lib, rename.as_slice())?; - Ok(json!({ "name": out })) -} - -fn expand_brush(s: &mut Session, p: &Value) -> Result { - let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); - let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); - if ids.is_empty() { - return Err(EngineError::Other("Expand: no brush strokes selected".into())); - } - let out = s.edit("Expand", |d, sel| { - let mut out = vec![]; - for id in &ids { - let Some(n) = d.node(*id).cloned() else { continue }; - let Some(g) = brush::expand(d, &n) else { continue }; - let g = d.reid(&g); - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let gid = g.id; - d.remove(*id)?; - d.insert(par, idx, g)?; - out.push(gid); - } - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -fn brush_freehand(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); - let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = (spec.run)(s, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) -} - -// ---------- symbols ---------- - -pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { - d.unknown - .get(SIZES_KEY) - .and_then(|m| m.get(name)) - .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) - .unwrap_or((2.0 * HALF, 2.0 * HALF)) -} - -pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { - let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); - if let Some(o) = m.as_object_mut() { - match size { - Some((w, h)) => o.insert(name.into(), json!([w, h])), - None => o.remove(name), - }; - } -} - -/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. -pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { - let c = at - reg.offset_from_center(w, h); - Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) -} - -fn registration_param(p: &Value, cmd: &str) -> Result> { - match p.get("registration") { - None => Ok(None), - Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), - } -} - -fn export_type_param(p: &Value, cmd: &str) -> Result> { - match str_param(p, "exportType") { - None => Ok(None), - Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), - } -} - -/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural -/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it -/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). -fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { - let on = match p.get("nineSlice") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), - }; - let guides = - match p.get("nineSliceGuides") { - None | Some(Value::Null) => None, - Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { - bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") - })?), - }; - Ok(match (on, guides) { - (Some(false), _) => Some(None), - (None, None) => None, - (_, Some(g)) => Some(Some(g)), - (Some(true), None) => Some(Some(current.unwrap_or_default())), - }) -} - -fn nine_slice_json(d: &Document, name: &str) -> Value { - d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) -} - -fn rename_natural_size(d: &mut Document, from: &str, to: &str) { - if from == to { - return; - } - let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); - set_natural_size(d, from, None); - if let Some(v) = size - && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) - { - set_natural_size(d, to, Some((w, h))); - } -} - -/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). -pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { - let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; - let (w, h) = (b.width().max(1.0), b.height().max(1.0)); - let c = b.center(); - let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); - art.transform(n, false); - Ok((art, (w, h), c)) -} - -fn instances_of(d: &Document, name: Option<&str>) -> Vec { - let mut v = vec![]; - d.walk(|n| { - if let NodeKind::SymbolInstance { symbol, .. } = &n.kind - && name.is_none_or(|x| x == symbol) - { - v.push(n.id); - } - }); - v -} - -fn symbol_names(d: &Document) -> Vec { - d.symbols.iter().map(|s| s.name.clone()).collect() -} - -fn current_symbol(d: &Document) -> Option { - d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) -} - -fn symbol_list(s: &mut Session, _: &Value) -> Result { - let d = &s.doc()?.doc; - let list: Vec = d - .symbols - .iter() - .map(|sym| { - let (w, h) = natural_size(d, &sym.name); - json!({ - "name": sym.name, - "instances": instances_of(d, Some(&sym.name)).len(), - "size": [w, h], - "registration": d.symbol_registration(&sym.name).0, - "exportType": d.symbol_export_type(&sym.name).id(), - "nineSlice": nine_slice_json(d, &sym.name), - }) - }) - .collect(); - Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) -} - -/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. -fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { - let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; - if separate(d, ids) { - let id = d.alloc_id(); - return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); - } - let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; - let id = d.alloc_id(); - d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; - for r in ids { - d.remove(*r)?; - } - Ok(id) -} - -fn symbol_new(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.new"; - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); - let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); - let export = export_type_param(p, C)?.unwrap_or_default(); - let nine = nine_slice_param(p, C, None)?.flatten(); - // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. - let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); - let id = s.edit("New Symbol", |d, sel| { - let art = d.reid(&art); - d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); - set_natural_size(d, &name, Some(size)); - d.set_symbol_registration(&name, reg); - d.set_symbol_export_type(&name, export); - d.set_symbol_nine_slice(&name, nine); - d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); - let threaded = super::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place)?; - // Frames of threaded text in the symbol leave their threads with the text they show. - super::threads::leave_threads(d, &threaded); - sel.set([id]); - Ok(id) - })?; - Ok( - json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), - ) -} - -fn symbol_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.options"; - let name = resolve_symbol(&s.doc()?.doc, p, C)?; - let export = export_type_param(p, C)?; - let reg = registration_param(p, C)?; - let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; - let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { - return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); - } - if let Some(ref n) = new_name - && n != &name - && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) - { - return Err(bad(C, format!("a symbol named `{n}` already exists"))); - } - let final_name = new_name.clone().unwrap_or_else(|| name.clone()); - s.edit("Symbol Options", |d, _| { - if let Some(ref n) = new_name - && n != &name - { - if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { - sym.name = n.clone(); - } - for id in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { - *symbol = n.clone(); - } - } - rename_natural_size(d, &name, n); - d.rename_symbol_registration(&name, n); - d.rename_symbol_export_type(&name, n); - d.rename_symbol_nine_slice(&name, n); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); - } - } - if let Some(r) = reg { - d.set_symbol_registration(&final_name, r); - } - if let Some(kind) = export { - d.set_symbol_export_type(&final_name, kind); - } - if let Some(g) = nine { - d.set_symbol_nine_slice(&final_name, g); - } - Ok(()) - })?; - let d = &s.doc()?.doc; - Ok(json!({ - "name": final_name, - "registration": d.symbol_registration(&final_name).0, - "exportType": d.symbol_export_type(&final_name).id(), - "nineSlice": nine_slice_json(d, &final_name), - })) -} - -fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { - match str_param(p, "name") { - Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), - Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), - None => { - current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) - } - } -} - -fn symbol_place(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let name = resolve_symbol(d, p, "symbol.place")?; - let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); - let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let parent = s.doc()?.target_parent()?; - let id = s.edit("Place Symbol Instance", |d, sel| { - let id = d.alloc_id(); - d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; - sel.set([id]); - Ok(id) - })?; - Ok(json!({ "id": id.0 })) -} - -/// The plain art of instance `inst` in document space (fresh ids). -fn expanded_instance(d: &mut Document, inst: &Node) -> Option { - let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; - let sym = d.symbols.iter().find(|s| &s.name == symbol)?; - let mut art = (*sym.art).clone(); - if let Some(f) = inst.appearance.fill() - && let Some(c) = f.paint.color() - { - brush::tint_node(&mut art, &c, f.opacity); - } - art.transform(*xf, false); - let mut art = d.reid(&art); - if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { - if !matches!(art.kind, NodeKind::Group { .. }) { - let id = d.alloc_id(); - art = Node::group(id, vec![Arc::new(art)]); - } - art.opacity *= inst.opacity; - art.blend = inst.blend; - } - Some(art) -} - -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { - let mut out = vec![]; - for id in ids { - let Some(inst) = d.node(*id).cloned() else { continue }; - let Some(art) = expanded_instance(d, &inst) else { continue }; - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let aid = art.id; - d.remove(*id)?; - d.insert(par, idx, art)?; - out.push(aid); - } - Ok(out) -} - -fn selected_instances(s: &Session, p: &Value) -> Result> { - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in roots(s, p)? { - if let Some(n) = d.node(id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) { - out.push(c.id); - } - }); - } - } - Ok(out) -} - -fn symbol_break_link(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - if ids.is_empty() { - return Err(EngineError::Other("no symbol instances selected".into())); - } - let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "ids": ids_json(&out) })) -} - -fn symbol_edit(s: &mut Session, p: &Value) -> Result { - let ids = selected_instances(s, p)?; - let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; - let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { - Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), - _ => return Err(EngineError::Other("select a symbol instance".into())), - }; - let out = s.edit("Edit Symbol", |d, sel| { - let out = break_links(d, &[first])?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(json!({ "name": name, "ids": ids_json(&out) })) -} - -fn symbol_update(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.update"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let ids = roots(s, p)?; - let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; - super::threads::keep_shown_text(&s.doc()?.doc, &mut art); - let (art, size, c) = normalize(art)?; - let old = natural_size(&s.doc()?.doc, &name); - let id = s.edit("Redefine Symbol", |d, sel| { - let art = d.reid(&art); - if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { - sym.art = Arc::new(art); - } - set_natural_size(d, &name, Some(size)); - let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); - for i in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { - *xf *= fix; - } - } - let reg = d.symbol_registration(&name); - let threaded = super::threads::frames(d); - let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; - // Frames of threaded text in the symbol leave their threads with the text they show. - super::threads::leave_threads(d, &threaded); - sel.set([id]); - Ok(id) - })?; - Ok(json!({ "name": name, "id": id.0 })) -} - -fn symbol_delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.delete"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let expand = switch_or(p, "expandInstances", true, C)?; - s.edit("Delete Symbol", |d, _| { - let inst = instances_of(d, Some(&name)); - if expand { - break_links(d, &inst)?; - } else { - remove_each(d, &inst)?; - } - d.symbols.retain(|x| x.name != name); - set_natural_size(d, &name, None); - d.set_symbol_registration(&name, SymbolRegistration::CENTER); - d.clear_symbol_export_type(&name); - d.set_symbol_nine_slice(&name, None); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.remove(CURRENT_SYMBOL); - } - Ok(()) - })?; - ok() -} - -fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.duplicate"; - let name = name_param(p, C)?.to_string(); - let d = &s.doc()?.doc; - let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; - let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); - let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); - let size = natural_size(d, &name); - let reg = d.symbol_registration(&name); - let export = d.symbol_export_type(&name); - let nine = d.symbol_nine_slice(&name); - s.edit("Duplicate Symbol", |d, _| { - let art = d.reid(&sym.art); - d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); - set_natural_size(d, &new, Some(size)); - d.set_symbol_registration(&new, reg); - d.set_symbol_export_type(&new, export); - d.set_symbol_nine_slice(&new, nine); - Ok(()) - })?; - Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) -} - -fn symbol_replace(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.replace"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { - return Err(bad(C, format!("no symbol named `{name}`"))); - } - let ids = selected_instances(s, p)?; - if ids.is_empty() { - return Err(EngineError::Other("no symbol instances selected".into())); - } - s.edit("Replace Symbol", |d, _| { - let new = natural_size(d, &name); - for id in &ids { - let old = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), - _ => continue, - }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { - *symbol = name.clone(); - *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); - } - } - Ok(()) - })?; - Ok(json!({ "ids": ids_json(&ids) })) -} - -fn symbol_set_current(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; - set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; - ok() -} - -fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { - let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; - let ids = instances_of(&s.doc()?.doc, Some(&name)); - let n = ids.len(); - s.select(|_, sel| sel.set(ids))?; - Ok(json!({ "count": n })) -} - -// ---------- symbolism tools ---------- - -fn points(p: &Value, cmd: &str) -> Result> { - let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; - let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); - if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { - return Err(bad(cmd, "points must be finite [x,y] pairs")); - } - Ok(v) -} - -/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). -fn jitter(i: usize, q: Point) -> (f64, f64, f64) { - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; - for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { - h ^= v; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - h ^= h >> 29; - } - let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; - (f(0), f(1), f(2)) -} - -fn is_symbol_set(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") -} - -fn symbol_spray(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.spray"; - let pts = points(p, C)?; - let st = s.doc()?; - let name = resolve_symbol(&st.doc, p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); - let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); - let alt = switch_or(p, "alt", false, C)?; - let size = natural_size(&st.doc, &name); - let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - // Only a new Symbol Set needs a layer that takes new art. - let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; - if alt { - // Remove instances of this symbol under the brush (within the set, or anywhere). - let d = &st.doc; - let scope: Vec = match set { - Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), - None => instances_of(d, Some(&name)), - }; - let doomed: Vec = scope - .into_iter() - .filter(|id| { - d.node(*id).is_some_and(|n| { - matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) - && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) - }) - }) - .collect(); - let n = doomed.len(); - if n > 0 { - s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; - } - return Ok(json!({ "count": n })); - } - // Keep instances at least this far apart along the stroke (denser = closer). - let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); - let mut placed: Vec = vec![]; - for (i, q) in pts.iter().enumerate() { - let (jx, jy, _) = jitter(i, *q); - let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); - if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { - continue; - } - placed.push(c); - } - let count = placed.len(); - let reg = s.doc()?.doc.symbol_registration(&name); - let id = s.edit("Symbol Sprayer", |d, sel| { - let mut nodes = vec![]; - for (i, c) in placed.iter().enumerate() { - let (_, _, js) = jitter(i + 7, *c); - let k = scale * (1.0 + js * 0.1); - let id = d.alloc_id(); - nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); - } - let gid = match set { - Some(g) => { - for n in nodes { - d.insert(Some(g), usize::MAX, n)?; - } - g - } - None => { - let gid = d.alloc_id(); - let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); - g.name = Some("Symbol Set".into()); - d.insert(parent, usize::MAX, g)?; - gid - } - }; - sel.set([gid]); - Ok(gid) - })?; - Ok(json!({ "id": id.0, "count": count })) -} - -fn instance_center(n: &Node) -> Option { - match &n.kind { - NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), - _ => None, - } -} - -fn symbol_adjust(s: &mut Session, p: &Value) -> Result { - const C: &str = "symbol.adjust"; - let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); - if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { - return Err(bad(C, format!("unknown tool `{tool}`"))); - } - super::paint::check_keep_model(p, C)?; - let pts = points(p, C)?; - let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); - let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; - let alt = switch_or(p, "alt", false, C)?; - let st = s.doc()?; - let color = match p.get("color").and_then(color_value) { - Some(c) => super::paint::new_color(s, p, c), - None => s.paint.fill.color().unwrap_or(Color::BLACK), - }; - let style_paint = match str_param(p, "style") { - Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), - None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), - }; - let candidates: Vec = { - let d = &st.doc; - let mut v = vec![]; - if st.selection.is_empty() { - v = instances_of(d, None); - } else { - for id in &st.selection.objects { - if let Some(n) = d.node(*id) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { - v.push(c.id); - } - }); - } - } - } - let editable = d.editable(); - v.retain(|id| editable(*id)); - v - }; - let label = match tool.as_str() { - "shift" => "Symbol Shifter", - "scrunch" => "Symbol Scruncher", - "size" => "Symbol Sizer", - "spin" => "Symbol Spinner", - "stain" => "Symbol Stainer", - "screen" => "Symbol Screener", - _ => "Symbol Styler", - }; - let count = s.edit(label, |d, _| { - let mut touched = std::collections::BTreeSet::new(); - for (j, q) in pts.iter().enumerate() { - let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; - for id in &candidates { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(c) = instance_center(n) else { continue }; - let dist = c.distance(*q); - if dist > radius { - continue; - } - let w = intensity * (1.0 - dist / radius).max(0.0); - if w <= 0.0 { - continue; - } - touched.insert(*id); - let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); - match tool.as_str() { - "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), - "scrunch" => { - let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; - n.transform(Affine::translate(v), false); - } - "size" => { - let k = 1.0 + 0.08 * w; - n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); - } - "spin" => { - // Turn towards the drag direction; a click spins a little. - let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; - n.transform(about(Affine::rotate(if alt { -a } else { a })), false); - } - "stain" | "style" => { - let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; - if tool == "style" && alt { - n.appearance.items.clear(); - continue; - } - let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); - let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; - let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; - n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); - if o > 0.0 { - let mut f = FillLayer::new(paint); - f.opacity = o; - n.appearance.items.insert(0, AppearanceItem::Fill(f)); - } - } - _ => { - let step = (0.1 * w) as f32; - n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; - } - } - } - } - Ok(touched.len()) - })?; - Ok(json!({ "count": count })) -} diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs new file mode 100644 index 000000000..18f744b1d --- /dev/null +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -0,0 +1,447 @@ +use super::*; + +// ---------- brushes ---------- + +/// Brushable nodes (paths and compound paths) under `ids`. +pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |c| { + let own = matches!(c.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }); + let in_compound = d.parent_of(c.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if own && !in_compound && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + out +} + +pub(super) fn brush_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = brush::library(d).iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect(); + Ok(json!({ "brushes": list, "current": brush::current(d) })) +} + +pub(super) fn brush_get(s: &mut Session, p: &Value) -> Result { + let name = name_param(p, "brush.get")?; + let b = brush::find(&s.doc()?.doc, name).ok_or_else(|| EngineError::Other(format!("no brush named `{name}`")))?; + Ok(serde_json::to_value(b).unwrap_or_default()) +} + +/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). +pub(super) fn brush_selection_art(s: &mut Session, p: &Value) -> Result { + let art = selection_art(s, p)?; + Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) +} + +/// The stroke paint a brush gives paths without one: the current stroke colour, else black. +pub(crate) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). +pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + if let Some(st) = n.appearance.stroke_mut() { + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.to_string()); + } + } +} + +pub(super) fn brush_apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.apply"; + let name = name_param(p, C)?.to_string(); + if brush::find(&s.doc()?.doc, &name).is_none() { + return Err(bad(C, format!("no brush named `{name}`"))); + } + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let stroke = stroke_paint(s); + s.edit("Apply Brush", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(name)); + apply_brush(d, &ids, &name, &stroke); + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_remove(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + s.edit("Remove Brush Stroke", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = it { + st.brush = None; + } + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn brush_set_current(s: &mut Session, p: &Value) -> Result { + match str_param(p, "name") { + Some(n) => { + if brush::find(&s.doc()?.doc, n).is_none() { + return Err(bad("brush.setCurrent", format!("no brush named `{n}`"))); + } + set_unknown(s, brush::CURRENT_KEY, Some(json!(n)))?; + } + None => set_unknown(s, brush::CURRENT_KEY, None)?, + } + ok() +} + +/// The definition fields that `params` change field by field when the brush has them as objects: +/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only +/// `start` keeps `end`. +const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; + +fn merge(base: &mut Value, patch: &Value) { + if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { + for (k, v) in pt { + match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { + (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { + for (f, fv) in fields { + inner.insert(f.clone(), fv.clone()); + } + } + _ => { + b.insert(k.clone(), v.clone()); + } + } + } + } +} + +/// Sets brush `to` on the strokes in `items` that paint with brush `from`. +fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from) + { + st.brush = to.map(str::to_string); + } + } +} + +/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. +fn paints_with(n: &Node, name: &str) -> bool { + let mut hit = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); + }); + hit +} + +/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that +/// hold such a stroke, and those that hold them, are copied. +fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { + set_stroke_brush(&mut n.appearance.items, from, to); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && paints_with(&mask.art, from) + { + set_brush_in(Arc::make_mut(&mut mask.art), from, to); + } + for c in n.children_mut().into_iter().flatten() { + if paints_with(c, from) { + set_brush_in(Arc::make_mut(c), from, to); + } + } +} + +/// Returns the art a brush is made of ([`Brush::art`]) for editing. +fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { + use vectorcraft_brush::BrushKind; + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + +/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art +/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern +/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. +fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if paints_with(n, from) { + set_brush_in(Arc::make_mut(n), from, to); + } + } + for g in &mut d.graphic_styles { + set_stroke_brush(&mut g.appearance.items, from, to); + } + for n in lib.iter_mut().flat_map(brush_art_mut) { + set_brush_in(n, from, to); + } +} + +fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { + s.edit(label, |d, _| { + for (from, to) in renames { + rename_brush_strokes(d, &mut lib, from, to.as_deref()); + if brush::current(d).as_deref() == Some(from.as_str()) { + match to { + Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), + None => d.unknown.remove(brush::CURRENT_KEY), + }; + } + } + brush::store(d, &lib); + Ok(()) + }) +} + +pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.new"; + let ty = str_param(p, "type").ok_or_else(|| bad(C, "missing `type`"))?; + let art_key = match ty { + "calligraphic" | "bristle" => None, + "scatter" | "art" => Some("art"), + "pattern" => Some("side"), + o => return Err(bad(C, format!("unknown brush type `{o}`"))), + }; + let mut lib = brush::library(&s.doc()?.doc); + let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); + let name = brush::unique_name(&lib, &base_name); + if ty == "bristle" { + bristle_name_fits(C, &name)?; + } + let mut v = json!({"name": name, "type": ty}); + if let Some(params) = p.get("params") { + merge(&mut v, params); + v["name"] = json!(name); + v["type"] = json!(ty); + } + if let Some(k) = art_key + && v.get(k).is_none() + { + let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; + if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + return Err(bad(C, "the selected art has no width")); + } + v[k] = serde_json::to_value(&art).unwrap_or_default(); + } + lib.push(parse_brush(C, v)?); + save_library(s, "New Brush", lib, &[])?; + Ok(json!({ "name": name })) +} + +/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. +fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { + let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); + if count > max { + return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); + } + Ok(()) +} + +/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). +fn parse_brush(cmd: &str, v: Value) -> Result { + Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) +} + +pub(super) fn brush_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.delete"; + let names = names_param(p, C)?; + let mut lib = brush::library(&s.doc()?.doc); + if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) { + return Err(bad(C, format!("no brush named `{name}`"))); + } + lib.retain(|b| !names.contains(&b.name)); + let renames: Vec<(String, Option)> = names.into_iter().map(|n| (n, None)).collect(); + save_library(s, "Delete Brush", lib, &renames)?; + ok() +} + +/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their +/// type in library order: before the first brush of that type at or after `to` that isn't moving, +/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` +/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. +pub(super) fn brush_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.move"; + const WITHIN: &str = "brushes move only within their type"; + let names = names_param(p, C)?; + check_numbers(p, &["to"], C)?; + let to = match p.get("to").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), + None => None, + }; + let old = brush::library(&s.doc()?.doc); + let mut lib = old.clone(); + // `names_param` refuses an empty list, so the loop sets `kind`. + let mut kind = ""; + for name in &names { + let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if !kind.is_empty() && kind != b.kind.type_id() { + return Err(bad(C, WITHIN)); + } + kind = b.kind.type_id(); + } + if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { + return Err(bad(C, WITHIN)); + } + let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); + let before = anchor(&same, to, &names, |b| &b.name); + let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); + let mut items = vec![]; + for name in &names { + if let Some(i) = lib.iter().position(|b| &b.name == name) { + items.push(lib.remove(i)); + } + } + let at = match before { + Some(a) => lib.iter().position(|b| b.name == a), + None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), + }; + let at = at.unwrap_or(first).min(lib.len()); + lib.splice(at..at, items); + if lib != old { + save_library(s, "Move Brush", lib, &[])?; + } + Ok(json!({ "moved": names.len() })) +} + +/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given +/// without `names`, names the copy. +pub(super) fn brush_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.duplicate"; + let names = names_param(p, C)?; + let before = brush::library(&s.doc()?.doc); + let mut lib = before.clone(); + let mut out = vec![]; + for name in &names { + let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); + let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); + b.name = brush::unique_name(&lib, &new_name); + out.push(b.name.clone()); + lib.push(b); + } + save_library(s, "Duplicate Brush", lib, &[])?; + Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) +} + +/// Adds the names of the brushes the strokes of `items` paint with to `out`. +fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && let Some(b) = &st.brush + { + out.push(b.clone()); + } + } +} + +/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to +/// `out`. +fn art_brushes(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); +} + +/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph +/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush +/// that a counted stroke paints with. +pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let lib = brush::library(d); + let mut todo = vec![]; + let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); + trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); + d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); + let mut used = BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + +pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.options"; + let name = name_param(p, C)?.to_string(); + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); + if let Some(params) = p.get("params") { + merge(&mut v, params); + } + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); + if let Some(nn) = &new_name { + if lib.iter().any(|b| &b.name == nn) { + return Err(bad(C, format!("a brush named `{nn}` exists"))); + } + // A `newName` that only trims the white space around the saved name is taken at any length. + if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { + bristle_name_fits(C, nn)?; + } + v["name"] = json!(nn); + } else { + v["name"] = json!(name); + } + v["type"] = json!(lib[i].kind.type_id()); + lib[i] = parse_brush(C, v)?; + let out = lib[i].name.clone(); + let rename = new_name.map(|nn| (name, Some(nn))); + save_library(s, "Brush Options", lib, rename.as_slice())?; + Ok(json!({ "name": out })) +} + +pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { + let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); + let ids: Vec = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); + if ids.is_empty() { + return Err(EngineError::Other("Expand: no brush strokes selected".into())); + } + let out = s.edit("Expand", |d, sel| { + let mut out = vec![]; + for id in &ids { + let Some(n) = d.node(*id).cloned() else { continue }; + let Some(g) = brush::expand(d, &n) else { continue }; + let g = d.reid(&g); + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let gid = g.id; + d.remove(*id)?; + d.insert(par, idx, g)?; + out.push(gid); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some())); + let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; + let r = (spec.run)(s, p)?; + let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; + s.edit("Paintbrush", |d, _| { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.clone()); + } + Ok(()) + })?; + Ok(r) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs new file mode 100644 index 000000000..63eeed6b5 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -0,0 +1,688 @@ +use super::*; + +// ---------- symbols ---------- + +pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { + d.unknown + .get(SIZES_KEY) + .and_then(|m| m.get(name)) + .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .unwrap_or((2.0 * HALF, 2.0 * HALF)) +} + +pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { + let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); + if let Some(o) = m.as_object_mut() { + match size { + Some((w, h)) => o.insert(name.into(), json!([w, h])), + None => o.remove(name), + }; + } +} + +/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. +pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { + let c = at - reg.offset_from_center(w, h); + Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) +} + +fn registration_param(p: &Value, cmd: &str) -> Result> { + match p.get("registration") { + None => Ok(None), + Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")), + } +} + +fn export_type_param(p: &Value, cmd: &str) -> Result> { + match str_param(p, "exportType") { + None => Ok(None), + Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))), + } +} + +/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural +/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it +/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). +fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { + let on = match p.get("nineSlice") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), + }; + let guides = + match p.get("nineSliceGuides") { + None | Some(Value::Null) => None, + Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { + bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") + })?), + }; + Ok(match (on, guides) { + (Some(false), _) => Some(None), + (None, None) => None, + (_, Some(g)) => Some(Some(g)), + (Some(true), None) => Some(Some(current.unwrap_or_default())), + }) +} + +fn nine_slice_json(d: &Document, name: &str) -> Value { + d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) +} + +fn rename_natural_size(d: &mut Document, from: &str, to: &str) { + if from == to { + return; + } + let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned(); + set_natural_size(d, from, None); + if let Some(v) = size + && let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64)) + { + set_natural_size(d, to, Some((w, h))); + } +} + +/// Normalise art for a symbol definition: returns (art, natural size, centre in the document). +pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { + let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; + let (w, h) = (b.width().max(1.0), b.height().max(1.0)); + let c = b.center(); + let n = Affine::scale_non_uniform(2.0 * HALF / w, 2.0 * HALF / h) * Affine::translate(-c.to_vec2()); + art.transform(n, false); + Ok((art, (w, h), c)) +} + +fn instances_of(d: &Document, name: Option<&str>) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && name.is_none_or(|x| x == symbol) + { + v.push(n.id); + } + }); + v +} + +fn symbol_names(d: &Document) -> Vec { + d.symbols.iter().map(|s| s.name.clone()).collect() +} + +fn current_symbol(d: &Document) -> Option { + d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str).filter(|n| d.symbols.iter().any(|s| s.name == *n)).map(str::to_string) +} + +pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let list: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({ + "name": sym.name, + "instances": instances_of(d, Some(&sym.name)).len(), + "size": [w, h], + "registration": d.symbol_registration(&sym.name).0, + "exportType": d.symbol_export_type(&sym.name).id(), + "nineSlice": nine_slice_json(d, &sym.name), + }) + }) + .collect(); + Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) })) +} + +/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id. +fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result { + let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + if separate(d, ids) { + let id = d.alloc_id(); + return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?); + } + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let id = d.alloc_id(); + d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?; + for r in ids { + d.remove(*r)?; + } + Ok(id) +} + +pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.new"; + let ids = roots(s, p)?; + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, c) = normalize(art)?; + let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol")); + let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); + let export = export_type_param(p, C)?.unwrap_or_default(); + let nine = nine_slice_param(p, C, None)?.flatten(); + // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. + let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); + let id = s.edit("New Symbol", |d, sel| { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) }); + set_natural_size(d, &name, Some(size)); + d.set_symbol_registration(&name, reg); + d.set_symbol_export_type(&name, export); + d.set_symbol_nine_slice(&name, nine); + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, &ids, &name, place)?; + // Frames of threaded text in the symbol leave their threads with the text they show. + crate::cmd::threads::leave_threads(d, &threaded); + sel.set([id]); + Ok(id) + })?; + Ok( + json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), + ) +} + +pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.options"; + let name = resolve_symbol(&s.doc()?.doc, p, C)?; + let export = export_type_param(p, C)?; + let reg = registration_param(p, C)?; + let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { + return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); + } + if let Some(ref n) = new_name + && n != &name + && s.doc()?.doc.symbols.iter().any(|x| x.name == *n) + { + return Err(bad(C, format!("a symbol named `{n}` already exists"))); + } + let final_name = new_name.clone().unwrap_or_else(|| name.clone()); + s.edit("Symbol Options", |d, _| { + if let Some(ref n) = new_name + && n != &name + { + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.name = n.clone(); + } + for id in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) { + *symbol = n.clone(); + } + } + rename_natural_size(d, &name, n); + d.rename_symbol_registration(&name, n); + d.rename_symbol_export_type(&name, n); + d.rename_symbol_nine_slice(&name, n); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); + } + } + if let Some(r) = reg { + d.set_symbol_registration(&final_name, r); + } + if let Some(kind) = export { + d.set_symbol_export_type(&final_name, kind); + } + if let Some(g) = nine { + d.set_symbol_nine_slice(&final_name, g); + } + Ok(()) + })?; + let d = &s.doc()?.doc; + Ok(json!({ + "name": final_name, + "registration": d.symbol_registration(&final_name).0, + "exportType": d.symbol_export_type(&final_name).id(), + "nineSlice": nine_slice_json(d, &final_name), + })) +} + +fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result { + match str_param(p, "name") { + Some(n) if d.symbols.iter().any(|s| s.name == n) => Ok(n.to_string()), + Some(n) => Err(bad(cmd, format!("no symbol named `{n}`"))), + None => { + current_symbol(d).or_else(|| symbol_names(d).into_iter().next()).ok_or_else(|| EngineError::Other("the document has no symbols".into())) + } + } +} + +pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let name = resolve_symbol(d, p, "symbol.place")?; + let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Symbol Instance", |d, sel| { + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The plain art of instance `inst` in document space (fresh ids). +fn expanded_instance(d: &mut Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = d.symbols.iter().find(|s| &s.name == symbol)?; + let mut art = (*sym.art).clone(); + if let Some(f) = inst.appearance.fill() + && let Some(c) = f.paint.color() + { + brush::tint_node(&mut art, &c, f.opacity); + } + art.transform(*xf, false); + let mut art = d.reid(&art); + if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { + if !matches!(art.kind, NodeKind::Group { .. }) { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.opacity *= inst.opacity; + art.blend = inst.blend; + } + Some(art) +} + +fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { + let mut out = vec![]; + for id in ids { + let Some(inst) = d.node(*id).cloned() else { continue }; + let Some(art) = expanded_instance(d, &inst) else { continue }; + let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; + let aid = art.id; + d.remove(*id)?; + d.insert(par, idx, art)?; + out.push(aid); + } + Ok(out) +} + +fn selected_instances(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in roots(s, p)? { + if let Some(n) = d.node(id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + out.push(c.id); + } + }); + } + } + Ok(out) +} + +pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let out = s.edit("Break Link to Symbol", |d, sel| { + let out = break_links(d, &ids)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&out) })) +} + +pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?; + let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) { + Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), + _ => return Err(EngineError::Other("select a symbol instance".into())), + }; + let out = s.edit("Edit Symbol", |d, sel| { + let out = break_links(d, &[first])?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "name": name, "ids": ids_json(&out) })) +} + +pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.update"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = roots(s, p)?; + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, c) = normalize(art)?; + let old = natural_size(&s.doc()?.doc, &name); + let id = s.edit("Redefine Symbol", |d, sel| { + let art = d.reid(&art); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.art = Arc::new(art); + } + set_natural_size(d, &name, Some(size)); + let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); + for i in instances_of(d, Some(&name)) { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + *xf *= fix; + } + } + let reg = d.symbol_registration(&name); + let threaded = crate::cmd::threads::frames(d); + let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; + // Frames of threaded text in the symbol leave their threads with the text they show. + crate::cmd::threads::leave_threads(d, &threaded); + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "name": name, "id": id.0 })) +} + +pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.delete"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let expand = switch_or(p, "expandInstances", true, C)?; + s.edit("Delete Symbol", |d, _| { + let inst = instances_of(d, Some(&name)); + if expand { + break_links(d, &inst)?; + } else { + remove_each(d, &inst)?; + } + d.symbols.retain(|x| x.name != name); + set_natural_size(d, &name, None); + d.set_symbol_registration(&name, SymbolRegistration::CENTER); + d.clear_symbol_export_type(&name); + d.set_symbol_nine_slice(&name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } + Ok(()) + })?; + ok() +} + +pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.duplicate"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?; + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()); + let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string)); + let size = natural_size(d, &name); + let reg = d.symbol_registration(&name); + let export = d.symbol_export_type(&name); + let nine = d.symbol_nine_slice(&name); + s.edit("Duplicate Symbol", |d, _| { + let art = d.reid(&sym.art); + d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + set_natural_size(d, &new, Some(size)); + d.set_symbol_registration(&new, reg); + d.set_symbol_export_type(&new, export); + d.set_symbol_nine_slice(&new, nine); + Ok(()) + })?; + Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) +} + +pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.replace"; + let name = name_param(p, C)?.to_string(); + if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + s.edit("Replace Symbol", |d, _| { + let new = natural_size(d, &name); + for id in &ids { + let old = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + _ => continue, + }; + if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + *symbol = name.clone(); + *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; + set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; + ok() +} + +pub(super) fn symbol_select_instances(s: &mut Session, p: &Value) -> Result { + let name = resolve_symbol(&s.doc()?.doc, p, "symbol.selectInstances")?; + let ids = instances_of(&s.doc()?.doc, Some(&name)); + let n = ids.len(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": n })) +} + +// ---------- symbolism tools ---------- + +fn points(p: &Value, cmd: &str) -> Result> { + let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?; + let v: Vec = a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(); + if v.is_empty() || v.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(cmd, "points must be finite [x,y] pairs")); + } + Ok(v) +} + +/// Deterministic per-point jitter in [-1, 1]² (so previews re-applied on the snapshot agree). +fn jitter(i: usize, q: Point) -> (f64, f64, f64) { + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ i as u64; + for v in [(q.x * 16.0).round() as i64 as u64, (q.y * 16.0).round() as i64 as u64] { + h ^= v; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + h ^= h >> 29; + } + let f = |k: u32| ((h.rotate_left(k * 21) & 0xffff) as f64 / 65535.0) * 2.0 - 1.0; + (f(0), f(1), f(2)) +} + +fn is_symbol_set(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some("Symbol Set") +} + +pub(super) fn symbol_spray(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.spray"; + let pts = points(p, C)?; + let st = s.doc()?; + let name = resolve_symbol(&st.doc, p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); + let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); + let alt = switch_or(p, "alt", false, C)?; + let size = natural_size(&st.doc, &name); + let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; + if alt { + // Remove instances of this symbol under the brush (within the set, or anywhere). + let d = &st.doc; + let scope: Vec = match set { + Some(g) => d.node(g).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(), + None => instances_of(d, Some(&name)), + }; + let doomed: Vec = scope + .into_iter() + .filter(|id| { + d.node(*id).is_some_and(|n| { + matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name) + && n.geometric_bounds().is_some_and(|b| pts.iter().any(|q| q.distance(b.center()) <= radius)) + }) + }) + .collect(); + let n = doomed.len(); + if n > 0 { + s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?; + } + return Ok(json!({ "count": n })); + } + // Keep instances at least this far apart along the stroke (denser = closer). + let min_gap = size.0.max(size.1) * scale * (1.15 - density * 0.1).max(0.1); + let mut placed: Vec = vec![]; + for (i, q) in pts.iter().enumerate() { + let (jx, jy, _) = jitter(i, *q); + let c = Point::new(q.x + jx * radius * 0.5, q.y + jy * radius * 0.5); + if placed.last().is_some_and(|l| l.distance(c) < min_gap) && i > 0 { + continue; + } + placed.push(c); + } + let count = placed.len(); + let reg = s.doc()?.doc.symbol_registration(&name); + let id = s.edit("Symbol Sprayer", |d, sel| { + let mut nodes = vec![]; + for (i, c) in placed.iter().enumerate() { + let (_, _, js) = jitter(i + 7, *c); + let k = scale * (1.0 + js * 0.1); + let id = d.alloc_id(); + nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) })); + } + let gid = match set { + Some(g) => { + for n in nodes { + d.insert(Some(g), usize::MAX, n)?; + } + g + } + None => { + let gid = d.alloc_id(); + let mut g = Node::group(gid, nodes.into_iter().map(Arc::new).collect()); + g.name = Some("Symbol Set".into()); + d.insert(parent, usize::MAX, g)?; + gid + } + }; + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "count": count })) +} + +fn instance_center(n: &Node) -> Option { + match &n.kind { + NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO), + _ => None, + } +} + +pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.adjust"; + let tool = str_param(p, "tool").ok_or_else(|| bad(C, "missing `tool`"))?.to_string(); + if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { + return Err(bad(C, format!("unknown tool `{tool}`"))); + } + crate::cmd::paint::check_keep_model(p, C)?; + let pts = points(p, C)?; + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; + let alt = switch_or(p, "alt", false, C)?; + let st = s.doc()?; + let color = match p.get("color").and_then(color_value) { + Some(c) => crate::cmd::paint::new_color(s, p, c), + None => s.paint.fill.color().unwrap_or(Color::BLACK), + }; + let style_paint = match str_param(p, "style") { + Some(n) => st.doc.graphic_styles.iter().find(|g| g.name == n).map(|g| g.appearance.fill_paint()), + None => st.doc.graphic_styles.get(1).or(st.doc.graphic_styles.first()).map(|g| g.appearance.fill_paint()), + }; + let candidates: Vec = { + let d = &st.doc; + let mut v = vec![]; + if st.selection.is_empty() { + v = instances_of(d, None); + } else { + for id in &st.selection.objects { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !v.contains(&c.id) { + v.push(c.id); + } + }); + } + } + } + let editable = d.editable(); + v.retain(|id| editable(*id)); + v + }; + let label = match tool.as_str() { + "shift" => "Symbol Shifter", + "scrunch" => "Symbol Scruncher", + "size" => "Symbol Sizer", + "spin" => "Symbol Spinner", + "stain" => "Symbol Stainer", + "screen" => "Symbol Screener", + _ => "Symbol Styler", + }; + let count = s.edit(label, |d, _| { + let mut touched = std::collections::BTreeSet::new(); + for (j, q) in pts.iter().enumerate() { + let delta = if j > 0 { *q - pts[j - 1] } else { Vec2::ZERO }; + for id in &candidates { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(c) = instance_center(n) else { continue }; + let dist = c.distance(*q); + if dist > radius { + continue; + } + let w = intensity * (1.0 - dist / radius).max(0.0); + if w <= 0.0 { + continue; + } + touched.insert(*id); + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + match tool.as_str() { + "shift" => n.transform(Affine::translate(delta * w.min(1.0)), false), + "scrunch" => { + let v = (*q - c) * (0.15 * w) * if alt { -1.0 } else { 1.0 }; + n.transform(Affine::translate(v), false); + } + "size" => { + let k = 1.0 + 0.08 * w; + n.transform(about(Affine::scale(if alt { 1.0 / k } else { k })), false); + } + "spin" => { + // Turn towards the drag direction; a click spins a little. + let a = if delta.hypot() > 1e-9 { (c - *q).cross(delta).signum() * 0.1 * w } else { 0.05 * w }; + n.transform(about(Affine::rotate(if alt { -a } else { a })), false); + } + "stain" | "style" => { + let paint = if tool == "style" { style_paint.clone().unwrap_or(Paint::solid(color)) } else { Paint::solid(color) }; + if tool == "style" && alt { + n.appearance.items.clear(); + continue; + } + let cur = n.appearance.fill().filter(|f| f.paint == paint).map(|f| f.opacity).unwrap_or(0.0); + let step = if tool == "style" { 1.0 } else { 0.15 * w as f32 }; + let o = if alt { (cur - step).max(0.0) } else { (cur + step).min(1.0) }; + n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Fill(_))); + if o > 0.0 { + let mut f = FillLayer::new(paint); + f.opacity = o; + n.appearance.items.insert(0, AppearanceItem::Fill(f)); + } + } + _ => { + let step = (0.1 * w) as f32; + n.opacity = if alt { (n.opacity + step).min(1.0) } else { (n.opacity - step).max(0.05) }; + } + } + } + } + Ok(touched.len()) + })?; + Ok(json!({ "count": count })) +}