mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
refactor(engine): move cmd/brushsym.rs items to cmd/brushsym/{brushes,symbols}.rs
This commit is contained in:
+14
-1133
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user