mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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.
1207 lines
49 KiB
Rust
1207 lines
49 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, 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 }))
|
||
}
|