refactor(engine): move cmd/brushsym.rs items to cmd/brushsym/{brushes,symbols}.rs

This commit is contained in:
aiongg
2026-10-26 22:01:24 +00:00
parent 7bbf803f7e
commit 48b3ea91f0
3 changed files with 1149 additions and 1133 deletions
File diff suppressed because it is too large Load Diff
+447
View File
@@ -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<NodeId> {
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<Value> {
let d = &s.doc()?.doc;
let list: Vec<Value> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Brush>, renames: &[(String, Option<String>)]) -> 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<Value> {
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> {
Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
}
pub(super) fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
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<String>)> = 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<Value> {
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<Value> {
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<String>) {
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<String>) {
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<Value> {
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<Value> {
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<Value> {
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
let ids: Vec<NodeId> = 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<Value> {
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)
}
+688
View File
@@ -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<Option<SymbolRegistration>> {
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<Option<SymbolExportType>> {
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<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
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<NodeId> {
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<String> {
d.symbols.iter().map(|s| s.name.clone()).collect()
}
fn current_symbol(d: &Document) -> Option<String> {
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<Value> {
let d = &s.doc()?.doc;
let list: Vec<Value> = 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<NodeId> {
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<Value> {
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<Value> {
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<String> {
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<Value> {
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<Node> {
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<Vec<NodeId>> {
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<Vec<NodeId>> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Vec<Point>> {
let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing points [[x,y]…]"))?;
let v: Vec<Point> = 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<Value> {
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<NodeId> = 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<NodeId> = 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<Point> = 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<Point> {
match &n.kind {
NodeKind::SymbolInstance { xf, .. } => Some(*xf * Point::ZERO),
_ => None,
}
}
pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = {
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 }))
}