Files
vectorcraft/crates/engine/src/cmd/brushsym.rs
T
dotCooCoo 2643f57b1f Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
In a CMYK document, paint.setFill stores an RGB color as CMYK through
the color settings, but the other commands that take a color stored it
as RGB: text.create, text.createInPath, text.setStyle and
text.setRangeStyle colors, a shapeBuilder.merge hex fill,
paint.editGradient stops, freeform gradient points, gradient mesh
points, the Symbol Stainer's color, and swatch.new {colors},
swatch.newGroup, swatch.editGroup and swatch.edit. The Mesh tool sent
the fill as a hex value, so a CMYK fill came in as RGB.

These commands now store such a color through paint::new_color, as
paint.setFill does, and take keepModel to keep RGB (shapeBuilder.merge
inside its fill object). A color the swatch
or gradient already has keeps its model, so stops sent back with a
moved offset keep their colors (the Gradient panel's ramp and the
Gradient tool send keepModel), and a stop that names a process
swatch takes the swatch's color as it is, in paint.setFill gradients
too. Where the app copies an existing color, it keeps the color's
model: the Color panel's sliders for gradient stops and freeform
points, a swatch put on a stop or a point (the stop popover, Alt-click
in the Swatches panel, a drop on the Gradient panel's ramp), the fill
the Mesh tool gives a new point or the Symbol Stainer applies, and the
colors the Color Guide, Color Themes and Recolor Artwork save as
swatches.

swatch.new {color, mode} converted RGB into CMYK before converting it
into mode, so the New Swatch dialog in RGB mode stored #00ff00 as
R101 G176 B62 in a CMYK document. With mode, the color is now
converted once.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:34:40 +07:00

1207 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Brushes (Brushes panel, Paintbrush, Expand) and Symbols (Symbols panel, symbolism tools).
//!
//! Brush definitions live in `vectorcraft-brush` and are stored in `Document::unknown["brushes"]`.
//! Symbol definitions are `Document::symbols`; symbols made here are *normalised*: the art is
//! centred on the origin and scaled (non-uniformly, strokes untouched) into the ±10 pt box that
//! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The
//! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::names_param;
use super::*;
use crate::EngineError;
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
const CURRENT_SYMBOL: &str = "currentSymbol";
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
pub fn specs() -> Vec<CommandSpec> {
vec![
// ----- brushes -----
cmd!(query "brush.list", "Brushes", [], None, "{} → {brushes: [{name, type}], current}", has_doc, brush_list),
cmd!(query "brush.get", "Brush Definition", [], None, "{name} → the brush definition (JSON)", has_doc, brush_get),
cmd!(
"brush.apply",
"Apply Brush",
[],
None,
"{name, ids?|id?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}",
has_doc,
brush_apply
),
cmd!(
"brush.remove",
"Remove Brush Stroke",
[],
None,
"{ids?|id?} remove brushes from the selected strokes → {ids}",
has_selection,
brush_remove
),
cmd!(
"brush.setCurrent",
"Current Brush",
[],
None,
"{name: string|null} the brush the Paintbrush tool paints with (not an undo step)",
has_doc,
brush_set_current
),
cmd!(
"brush.new",
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
has_doc,
brush_new
),
cmd!(
"brush.delete",
"Delete Brush",
[],
None,
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
has_doc,
brush_delete
),
cmd!(
"brush.duplicate",
"Duplicate Brush",
[],
None,
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
has_doc,
brush_duplicate
),
cmd!(
"brush.options",
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
cmd!(
"object.expandBrush",
"Expand Brush Strokes",
[],
None,
"{ids?|id?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
has_selection,
expand_brush
),
cmd!(
"brush.freehand",
"Paintbrush",
[],
None,
"{…params as path.freehand, brush} paint a freehand stroke with a brush → {id}",
has_doc,
brush_freehand
),
// ----- symbols -----
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
cmd!(
"symbol.new",
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
has_selection,
symbol_new
),
cmd!(
"symbol.options",
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
has_doc,
symbol_options
),
cmd!(
"symbol.place",
"Place Symbol Instance",
[],
None,
"{name?, x?, y?} place an instance so its registration point is at (x, y) (default: artboard centre; name default: current) → {id}",
has_doc,
symbol_place
),
cmd!(
"symbol.breakLink",
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art → {ids}",
has_selection,
symbol_break_link
),
cmd!(
"symbol.edit",
"Edit Symbol",
[],
None,
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}",
has_selection,
symbol_edit
),
cmd!(
"symbol.update",
"Redefine Symbol",
[],
None,
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
has_selection,
symbol_update
),
cmd!(
"symbol.delete",
"Delete Symbol",
[],
None,
"{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)",
has_doc,
symbol_delete
),
cmd!("symbol.duplicate", "Duplicate Symbol", [], None, "{name, newName?} → {name}", has_doc, symbol_duplicate),
cmd!(
"symbol.replace",
"Replace Symbol",
[],
None,
"{name, ids?|id?} swap the symbol of the selected instances → {ids}",
has_selection,
symbol_replace
),
cmd!(
"symbol.setCurrent",
"Current Symbol",
[],
None,
"{name} the symbol the symbolism tools spray (not an undo step)",
has_doc,
symbol_set_current
),
cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances),
cmd!(
"symbol.spray",
"Symbol Sprayer",
[],
None,
"{points: [[x,y]…], name?, radius?: 40, density?: 1..10 (5), scale?: 1, alt?: bool (remove instances)} spray instances into a symbol set → {id, count}",
has_doc,
symbol_spray
),
cmd!(
"symbol.adjust",
"Symbolism Tool",
[],
None,
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
has_doc,
symbol_adjust
),
]
}
fn ids_json(ids: &[NodeId]) -> Value {
Value::Array(ids.iter().map(|i| Value::from(i.0)).collect())
}
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects, none inside another of them.
let d = &s.doc()?.doc;
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
v.dedup();
Ok(outermost(d, v))
}
None => selected_roots(s),
}
}
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`.
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
}
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) }))
}
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())
}
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 = if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() };
s.edit("Apply Brush", |d, _| {
d.unknown.insert(brush::CURRENT_KEY.into(), json!(name));
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.clone());
}
}
Ok(())
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
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) }))
}
/// Set a document preference outside the undo history.
fn set_unknown(s: &mut Session, key: &str, v: Option<Value>) -> Result<()> {
let st = s.doc_mut()?;
let d = Arc::make_mut(&mut st.doc);
match v {
Some(v) => d.unknown.insert(key.into(), v),
None => d.unknown.remove(key),
};
Ok(())
}
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 selection as one art node (a group for several objects), ids kept.
pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
let d = &s.doc()?.doc;
let ids = roots(s, p)?;
let nodes: Vec<Node> = ids.iter().filter_map(|id| d.node(*id).cloned()).collect();
Ok(match nodes.len() {
0 => None,
1 => nodes.into_iter().next(),
_ => Some(Node::group(NodeId(0), nodes.into_iter().map(Arc::new).collect())),
})
}
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 {
b.insert(k.clone(), v.clone());
}
}
}
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
s.edit(label, |d, _| {
brush::store(d, &lib);
for (from, to) in renames {
let ids: Vec<NodeId> = {
let mut v = vec![];
d.walk(|n| {
if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) {
v.push(n.id);
}
});
v
};
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.as_deref() == Some(from.as_str())
{
st.brush = to.clone();
}
}
}
}
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),
};
}
}
Ok(())
})
}
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);
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 }))
}
/// A brush definition from command params, its values kept within their ranges.
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
b.sanitize();
Ok(b)
}
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()
}
/// 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<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() }) })
}
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")));
}
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<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) }))
}
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)
}
// ---------- symbols ----------
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))
}
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`.
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).
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)
}
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)
}
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.new";
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
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 id = replace_with_instance(d, &ids, &name, place)?;
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<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()))
}
}
}
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(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
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)
}
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) }))
}
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) }))
}
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 art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
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 id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0 }))
}
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 = bool_or(p, "expandInstances", true);
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<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) }))
}
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) }))
}
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()
}
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")
}
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 = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0);
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0);
let alt = bool_or(p, "alt", false);
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,
}
}
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}`")));
}
let pts = points(p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
let alt = bool_or(p, "alt", false);
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<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);
}
});
}
}
}
v.retain(|id| d.is_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 }))
}