Files
vectorcraft/crates/engine/src/cmd/brushsym.rs
T
haven b843fe8af3 M7: inline symbols in text
Document symbols placed inline in a run of type (beyond Illustrator; like
InDesign inline anchored objects): TextRun.inline holds the symbol, scale and
shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on
the cap height. text.insertInline and Type > Insert Inline Symbol; editing
keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create
Outlines turns it into a symbol instance.
2026-10-08 15:45:34 -07:00

1022 lines
39 KiB
Rust

//! 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};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::selected_roots;
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?} 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?} 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?, params?: {…definition fields}, ids?} 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} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
cmd!(
"brush.options",
"Brush Options…",
[],
None,
"{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
cmd!(
"object.expandBrush",
"Expand Brush Strokes",
[],
None,
"{ids?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
has_selection,
expand_brush
),
cmd!(
"brush.freehand",
"Paintbrush",
[],
None,
"{…path.freehand params, 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]}], current}", has_doc, symbol_list),
cmd!(
"symbol.new",
"New Symbol…",
[],
None,
"{name?, ids?} make a symbol from the selection and replace it with an instance → {name, id}",
has_selection,
symbol_new
),
cmd!(
"symbol.place",
"Place Symbol Instance",
[],
None,
"{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}",
has_doc,
symbol_place
),
cmd!(
"symbol.breakLink",
"Break Link to Symbol",
[],
None,
"{ids?} expand selected instances into plain art → {ids}",
has_selection,
symbol_break_link
),
cmd!(
"symbol.edit",
"Edit Symbol",
[],
None,
"{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?} 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?} 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?, 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.
let d = &s.doc()?.doc;
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
v.dedup();
Ok(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.
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>, rename: Option<(String, Option<String>)>) -> Result<()> {
s.edit(label, |d, _| {
brush::store(d, &lib);
if let Some((from, to)) = rename {
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 base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
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();
}
let b: Brush = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
lib.push(b);
save_library(s, "New Brush", lib, None)?;
Ok(json!({ "name": name }))
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.delete")?.to_string();
let mut lib = brush::library(&s.doc()?.doc);
let n = lib.len();
lib.retain(|b| b.name != name);
if lib.len() == n {
return Err(bad("brush.delete", format!("no brush named `{name}`")));
}
save_library(s, "Delete Brush", lib, Some((name, None)))?;
ok()
}
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.duplicate")?;
let mut lib = brush::library(&s.doc()?.doc);
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let out = b.name.clone();
lib.push(b);
save_library(s, "Duplicate Brush", lib, None)?;
Ok(json!({ "name": out }))
}
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").filter(|n| !n.trim().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] = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
let out = lib[i].name.clone();
let rename = new_name.map(|nn| (name, Some(nn)));
save_library(s, "Brush Options", lib, rename)?;
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)` centred at `c`.
fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine {
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
}
/// 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 unique_symbol_name(d: &Document, base: &str) -> String {
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
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]})
})
.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()))?;
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> {
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 = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
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.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0 }))
}
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 c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let size = natural_size(d, &name);
let parent = s.doc()?.insertion_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(c, size) }))?;
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 id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
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 {
for i in inst {
d.remove(i)?;
}
}
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &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 new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &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));
Ok(())
})?;
Ok(json!({ "name": new }))
}
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));
let parent = st.insertion_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, _| {
for id in &doomed {
d.remove(*id)?;
}
Ok(())
})?;
}
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 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) * 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) => 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 }))
}