mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
paint.invert and paint.complement on the Fill proxy, the Edit Colors commands, the Blend commands and recolor.apply now recolor the color that colorizes a grayscale or 1-bit image, as one undo step with the other targets. Before, they left it unchanged. Expand Appearance and Object > Expand bake color adjustment effects into that color too. - A colorized image's pixels are not recolored; they stay the gray coverage of its color. A stroke-only pass reaches the image's stroke items and leaves its colorize color unchanged. - A command that would leave a process color on a grayscale image with an alpha channel fails with the existing message and changes nothing. Object > Expand undoes its earlier steps when that happens. - A colorized image counts as a filled object in a blend.
915 lines
44 KiB
Rust
915 lines
44 KiB
Rust
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
|
||
|
||
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
|
||
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
|
||
use super::{
|
||
Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok,
|
||
paint_targets, str_param, switch_or,
|
||
};
|
||
use crate::{DocState, EngineError};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"paint.setFill",
|
||
"Fill",
|
||
[],
|
||
None,
|
||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||
has_doc,
|
||
|s, p| set_paint(s, p, true)
|
||
),
|
||
cmd!(
|
||
"paint.setStroke",
|
||
"Stroke",
|
||
[],
|
||
None,
|
||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke",
|
||
has_doc,
|
||
|s, p| { set_paint(s, p, false) }
|
||
),
|
||
cmd!(
|
||
"paint.swap",
|
||
"Swap Fill and Stroke",
|
||
[],
|
||
Some("Shift+X"),
|
||
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults",
|
||
has_doc,
|
||
swap
|
||
),
|
||
cmd!(
|
||
"paint.default",
|
||
"Default Fill and Stroke",
|
||
[],
|
||
Some("D"),
|
||
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke",
|
||
has_doc,
|
||
default_paint
|
||
),
|
||
cmd!(
|
||
"paint.toggleActive",
|
||
"Toggle Fill/Stroke Focus",
|
||
[],
|
||
Some("X"),
|
||
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
|
||
always,
|
||
|s, p| {
|
||
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
|
||
Ok(json!({ "fillActive": s.fill_active }))
|
||
}
|
||
),
|
||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
|
||
let f = s.fill_active;
|
||
set_paint(s, &json!({"none": true}), f)
|
||
}),
|
||
cmd!(
|
||
"transparency.set",
|
||
"Transparency",
|
||
["Window", "Transparency"],
|
||
None,
|
||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)",
|
||
has_doc,
|
||
transparency
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Commands of the Fill/Stroke proxies, registered at the end of the command list.
|
||
pub fn proxy_specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"paint.invert",
|
||
"Invert",
|
||
[],
|
||
None,
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}",
|
||
has_doc,
|
||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||
),
|
||
cmd!(
|
||
"paint.complement",
|
||
"Complement",
|
||
[],
|
||
None,
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}",
|
||
has_doc,
|
||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||
),
|
||
cmd!(
|
||
"paint.lastColor",
|
||
"Apply Last Color",
|
||
[],
|
||
Some(","),
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||
has_doc,
|
||
|s, p| {
|
||
let paint = Paint::solid(s.last_solid);
|
||
apply_to_proxy(s, p, paint, "paint.lastColor")
|
||
}
|
||
),
|
||
cmd!(
|
||
"paint.lastGradient",
|
||
"Apply Last Gradient",
|
||
[],
|
||
Some("."),
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||
has_doc,
|
||
|s, p| {
|
||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||
apply_to_proxy(s, p, paint, "paint.lastGradient")
|
||
}
|
||
),
|
||
cmd!(
|
||
query "paint.recent",
|
||
"Recent Colors",
|
||
[],
|
||
None,
|
||
"{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}",
|
||
always,
|
||
|s, _| {
|
||
let c = |c: &Color| json!({"hex": c.to_hex(), "color": c});
|
||
Ok(json!({
|
||
"colors": s.recent_colors.iter().map(c).collect::<Vec<_>>(),
|
||
"lastColor": c(&s.last_solid),
|
||
"lastGradient": s.last_gradient,
|
||
}))
|
||
}
|
||
),
|
||
cmd!(
|
||
query "paint.proxies",
|
||
"Fill and Stroke",
|
||
[],
|
||
None,
|
||
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}",
|
||
always,
|
||
proxies
|
||
),
|
||
]
|
||
}
|
||
|
||
impl Session {
|
||
/// How many colours the Recent Colors rows keep.
|
||
pub const RECENT_MAX: usize = 10;
|
||
|
||
/// Remember an applied paint: a solid colour becomes the last colour and the newest recent
|
||
/// colour, a gradient the last gradient. During a live preview this waits for the commit.
|
||
pub(crate) fn remember_paint(&mut self, p: &Paint) {
|
||
if self.in_interaction() {
|
||
self.pending_paint = Some(p.clone());
|
||
} else {
|
||
self.remember_paint_now(p);
|
||
}
|
||
}
|
||
|
||
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
|
||
/// first selected object's own stack) for the proxy of its kind, else the first selected
|
||
/// object's (a group's first painted object's; type: its first run's), else the defaults for
|
||
/// new art.
|
||
pub fn proxy_paints(&self) -> (Paint, Paint) {
|
||
let show = |stroke: bool| match self.proxy_source(stroke) {
|
||
Some((n, item)) => proxy_paint(n, stroke, item),
|
||
None if stroke => self.paint.stroke.clone(),
|
||
None => self.paint.fill.clone(),
|
||
};
|
||
(show(false), show(true))
|
||
}
|
||
|
||
/// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item
|
||
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art.
|
||
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option<usize>)> {
|
||
let st = self.active()?;
|
||
let first = st.doc.node(*st.selection.subjects().first()?)?;
|
||
let item = self.appearance_item();
|
||
if first.appearance.item_of_kind(item, !stroke).is_some() {
|
||
return Some((first, item));
|
||
}
|
||
let mut nodes = vec![];
|
||
painted(first, true, &mut nodes);
|
||
nodes.first().map(|n| (*n, None))
|
||
}
|
||
|
||
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
|
||
match p {
|
||
Paint::Solid { color, .. } => {
|
||
self.last_solid = *color;
|
||
self.recent_colors.retain(|c| c != color);
|
||
self.recent_colors.insert(0, *color);
|
||
self.recent_colors.truncate(Self::RECENT_MAX);
|
||
}
|
||
Paint::Gradient(g) => self.last_gradient = (**g).clone(),
|
||
Paint::None | Paint::Pattern { .. } => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint`]: fill or stroke `item` when it
|
||
/// is of that kind, type's first run, else the topmost one).
|
||
pub(crate) fn proxy_paint(n: &Node, stroke: bool, item: Option<usize>) -> Paint {
|
||
match n.proxy_paint(stroke, item) {
|
||
Some((p, ..)) => p.clone(),
|
||
// Type without runs shows the default character style.
|
||
None if matches!(n.kind, NodeKind::Text(_)) => {
|
||
let st = CharStyle::default();
|
||
if stroke { st.stroke } else { st.fill }
|
||
}
|
||
None => Paint::None,
|
||
}
|
||
}
|
||
|
||
/// Do two paints look the same in a proxy? Solid colours compare without their swatch link and
|
||
/// gradients without their per-object geometry.
|
||
fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
|
||
match (a, b) {
|
||
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => x == y,
|
||
(Paint::Gradient(x), Paint::Gradient(y)) => x.gradient == y.gradient,
|
||
_ => a == b,
|
||
}
|
||
}
|
||
|
||
/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands
|
||
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
|
||
/// other containers inside them), a compound path for its parts.
|
||
pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||
let expand = match n.kind {
|
||
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
|
||
NodeKind::Compound { .. } => false,
|
||
_ => !top && n.is_container(),
|
||
};
|
||
if expand {
|
||
n.children().into_iter().flatten().for_each(|c| painted(c, false, out));
|
||
} else {
|
||
out.push(n);
|
||
}
|
||
}
|
||
|
||
impl DocState {
|
||
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
|
||
/// proxy; a group whose contents differ counts). `item` (the Appearance panel's active item)
|
||
/// stands for the proxy of its kind, as in [`Session::proxy_paints`]: that fill or stroke of
|
||
/// each selected object's own stack is compared. One walk of the document, so callers that ask
|
||
/// every frame cache it by revision.
|
||
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
|
||
if self.selection.is_empty() {
|
||
return (false, false);
|
||
}
|
||
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
|
||
let (mut selected, mut nodes) = (vec![], vec![]);
|
||
self.doc.walk(|n| {
|
||
if ids.contains(&n.id) {
|
||
selected.push(n);
|
||
painted(n, true, &mut nodes);
|
||
}
|
||
});
|
||
let first = self.selection.objects.first().and_then(|id| self.doc.node(*id));
|
||
let differ = |stroke: bool| {
|
||
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
|
||
let set = if item.is_some() { &selected } else { &nodes };
|
||
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
|
||
let Some(p0) = paints.next() else { return false };
|
||
paints.any(|p| !same_in_proxy(&p0, &p))
|
||
};
|
||
(differ(false), differ(true))
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
|
||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
|
||
}
|
||
}
|
||
|
||
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let (fill, stroke) = s.proxy_paints();
|
||
let (fill_mixed, stroke_mixed) = s.proxy_mixed();
|
||
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
|
||
}
|
||
|
||
/// `stroke` param, defaulting to the proxy that is in front.
|
||
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||
switch_or(p, "stroke", !s.fill_active, cmd)
|
||
}
|
||
|
||
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
|
||
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
|
||
/// `keepModel` calls it before it changes anything.
|
||
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
|
||
check_switches(p, &["keepModel"], cmd)
|
||
}
|
||
|
||
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
|
||
/// an RGB one, or with `keepModel`).
|
||
fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Model> {
|
||
let mode = s.active()?.doc.color_mode;
|
||
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
|
||
}
|
||
|
||
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
|
||
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
|
||
/// are returned as given.
|
||
pub(crate) fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||
match (c, model) {
|
||
(Color::Rgb { .. }, Some(m)) => {
|
||
let cms = vectorcraft_color::cms::active();
|
||
cms.convert(&c, m, cms.settings().intent)
|
||
}
|
||
_ => c,
|
||
}
|
||
}
|
||
|
||
/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without
|
||
/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]).
|
||
pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||
stored_color(new_color_model(s, p), c)
|
||
}
|
||
|
||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
|
||
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
|
||
/// other than true, false or null is an error.
|
||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||
check_keep_model(p, "paint")?;
|
||
let model = new_color_model(s, p);
|
||
let new_color = |c: Color| stored_color(model, c);
|
||
if switch_or(p, "none", false, "paint")? {
|
||
return Ok(Some(Paint::None));
|
||
}
|
||
if let Some(name) = str_param(p, "swatch") {
|
||
let d = &s.doc()?.doc;
|
||
// A pattern definition works as its swatch even without a swatch entry.
|
||
if d.swatch(name).is_none() && d.pattern(name).is_some() {
|
||
return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name)));
|
||
}
|
||
let sw = d.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
|
||
let tint = tint_param(p, "paint")?;
|
||
return match &sw.paint {
|
||
Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)),
|
||
_ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")),
|
||
Paint::Gradient(g) => Ok(Some(Paint::Gradient(Box::new(GradientPaint { swatch: Some(name.to_string()), ..(**g).clone() })))),
|
||
other => Ok(Some(other.clone())),
|
||
};
|
||
}
|
||
if let Some(g) = p.get("gradient") {
|
||
check_numbers(g, &["angle"], "paint")?;
|
||
// A stop naming a process swatch keeps the swatch's model.
|
||
let stored = super::gradient::stop_colors(s, p, g);
|
||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||
let mut gp = super::gradient::parse_gradient(&super::gradient::with_stored_stops(&g, &Paint::None, &stored)).map_err(|e| bad("paint", e))?;
|
||
if g.get("stops").is_none() {
|
||
// The default stops, white to black.
|
||
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
|
||
}
|
||
if let Some(f) = &mut gp.freeform {
|
||
f.points.iter_mut().for_each(|pt| pt.color = new_color(pt.color));
|
||
}
|
||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||
}
|
||
if let Some(c) = p.get("color") {
|
||
let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?;
|
||
return Ok(Some(Paint::solid(new_color(c))));
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
/// The `tint` param (a percentage) as 0..1.
|
||
pub(crate) fn tint_param(p: &Value, cmd: &str) -> Result<Option<f32>> {
|
||
p.get("tint")
|
||
.filter(|v| !v.is_null())
|
||
.map(|v| v.as_f64().map(|t| (t / 100.0).clamp(0.0, 1.0) as f32).ok_or_else(|| bad(cmd, "`tint` must be a number (%)")))
|
||
.transpose()
|
||
}
|
||
|
||
/// The solid paint colour swatch `name` applies at `tint` (0..1): a global or spot colour links to
|
||
/// itself (default 100%), a tint swatch to its base (default: its own tint), a process colour is
|
||
/// just its colour (it has no tints).
|
||
pub(crate) fn swatch_solid(d: &Document, name: &str, tint: Option<f32>) -> std::result::Result<Paint, String> {
|
||
let sw = d.swatch(name).ok_or_else(|| format!("no swatch `{name}`"))?;
|
||
if sw.paint.color().is_none() {
|
||
return Err(format!("`{name}` isn't a colour swatch"));
|
||
}
|
||
match (d.swatch_link(name), tint) {
|
||
// A tint swatch whose base is gone keeps its colour.
|
||
(Some((base, own)), _) => Ok(d.tint_paint(&base, tint.unwrap_or(own)).unwrap_or_else(|| sw.paint.clone())),
|
||
(None, None) => Ok(sw.paint.clone()),
|
||
(None, Some(_)) => Err(format!("`{name}` is a process colour: tints apply to global and spot colours")),
|
||
}
|
||
}
|
||
|
||
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||
if p.get("tint").is_some() && p.get("swatch").is_none() {
|
||
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
|
||
}
|
||
check_keep_model(p, cmd)?;
|
||
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
|
||
// As `paint.editGradient`: a `dither` given applies to gradient fills only. A gradient swatch
|
||
// (named) keeps its own dither; `"swatch": null` names none.
|
||
if !fill && matches!(&paint, Paint::Gradient(g) if g.gradient.dither) && p.get("swatch").and_then(Value::as_str).is_none() {
|
||
return Err(bad(cmd, "`dither` applies to gradient fills (paint.setFill)"));
|
||
}
|
||
apply_paint(s, p, paint, fill)
|
||
}
|
||
|
||
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
|
||
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
|
||
let fill = !stroke_param(s, p, cmd)?;
|
||
apply_paint(s, p, paint, fill)
|
||
}
|
||
|
||
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
|
||
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
|
||
/// remember the paint. Replies `{objects}`: how many objects took the paint.
|
||
pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||
let focus = switch_or(p, "focus", true, cmd)?;
|
||
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
|
||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||
if p.get("ids").is_none()
|
||
&& p.get("item").is_none()
|
||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||
&& a != b
|
||
{
|
||
// When the type comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
|
||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||
let (xf, lb) = (t.xf, t.local_bounds());
|
||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||
if fill {
|
||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||
} else {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(st);
|
||
}
|
||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||
}
|
||
});
|
||
super::typecmd::refresh_bounds(t);
|
||
Ok(super::appearance::count_changed(d, &before, &[id]))
|
||
})?;
|
||
if focus {
|
||
s.fill_active = fill;
|
||
}
|
||
s.remember_paint(&paint);
|
||
return Ok(json!({"objects": 1}));
|
||
}
|
||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||
let selected = item.targets(s, p)?;
|
||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
|
||
// recolours just those points of their meshes.
|
||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||
// Without `item`, unless the Appearance panel's active fill item takes the paint.
|
||
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
|
||
colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))?
|
||
} else {
|
||
vec![]
|
||
};
|
||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||
edit_items_then(
|
||
s,
|
||
&ids,
|
||
item,
|
||
cmd,
|
||
label,
|
||
fill,
|
||
|n, index| {
|
||
if index.is_none() && colorized.contains(&n.id) {
|
||
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
|
||
return Ok(());
|
||
}
|
||
if index.is_none()
|
||
&& let Paint::Solid { color, .. } = &paint
|
||
&& let Some((_, points)) = mesh_points.iter().find(|(id, _)| *id == n.id)
|
||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||
{
|
||
for i in points {
|
||
if let Some(q) = m.points.get_mut(*i) {
|
||
q.color = *color;
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
if index.is_none()
|
||
&& let NodeKind::Text(t) = &mut n.kind
|
||
{
|
||
let (xf, lb) = (t.xf, t.local_bounds());
|
||
for r in &mut t.runs {
|
||
if !fill && !paint.is_none() {
|
||
run_stroke_weight(&mut r.style);
|
||
}
|
||
let (cur, b) = run_paint_mut(r, !fill, lb);
|
||
*cur = place_run_paint(&paint, p, xf, b);
|
||
}
|
||
return Ok(());
|
||
}
|
||
// A missing top fill or stroke is created first: a new stroke's weight sizes the box its
|
||
// gradient fits.
|
||
if n.appearance.paint_at(index, fill).is_none() {
|
||
n.appearance.set_paint_at(index, fill, Paint::None);
|
||
}
|
||
if !fill {
|
||
restore_unpainted_stroke(&mut n.appearance, index, &paint);
|
||
}
|
||
let new_paint = keep_pattern_xf(&paint, p, n.appearance.paint_at(index, fill));
|
||
let placed = place_paint(&new_paint, p, item_paint_bounds(n, index, fill));
|
||
n.appearance.set_paint_at(index, fill, placed);
|
||
if !fill
|
||
&& paint.is_none()
|
||
&& let Some(stroke) = n.appearance.stroke_at_mut(index)
|
||
{
|
||
// A stroke without paint has no weight; painting it again restores the 1 pt default.
|
||
stroke.width = 0.0;
|
||
}
|
||
Ok(())
|
||
},
|
||
|d, changed| {
|
||
let series = super::graph::capture_series_paints(d, changed, fill);
|
||
let labels = super::graph::capture_label_styles(d, changed, &|st| {
|
||
if fill {
|
||
st.fill = paint.clone();
|
||
} else {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(st);
|
||
}
|
||
st.stroke = paint.clone();
|
||
}
|
||
});
|
||
Ok(series || labels)
|
||
},
|
||
)?;
|
||
// Rejected edits leave the next shape's appearance and active proxy untouched.
|
||
// New art takes an unplaced gradient so that it fits the new object's own bounds.
|
||
if fill {
|
||
s.paint.fill = unplaced(&paint);
|
||
} else {
|
||
if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 {
|
||
s.paint.stroke_width = 1.0;
|
||
}
|
||
s.paint.stroke = unplaced(&paint);
|
||
}
|
||
if focus {
|
||
s.fill_active = fill;
|
||
}
|
||
s.remember_paint(&paint);
|
||
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
|
||
}
|
||
|
||
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets
|
||
/// would each fit to their own bounds can span them: one with its own vector (`start`/`end`),
|
||
/// a freeform one or a gradient swatch already has its placement.
|
||
fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result<bool, String> {
|
||
match p.get("span") {
|
||
None | Some(Value::Null) => return Ok(false),
|
||
Some(Value::String(v)) if v == "object" => return Ok(false),
|
||
Some(Value::String(v)) if v == "selection" => {}
|
||
Some(v) => return Err(format!("span must be \"object\" or \"selection\", got {v}")),
|
||
}
|
||
let Paint::Gradient(g) = paint else { return Err("span: \"selection\" places a gradient: give `gradient`".into()) };
|
||
if p.get("swatch").is_some() {
|
||
return Err("span: \"selection\" places a `gradient`, not a swatch".into());
|
||
}
|
||
if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() {
|
||
return Err("span: \"selection\" places a linear or radial gradient, not a freeform one".into());
|
||
}
|
||
if g.geom.is_some() {
|
||
return Err(
|
||
"span: \"selection\" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates)".into()
|
||
);
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// `paint` placed once on the union of the bounds of `ids` (each one's fill box, or the box its
|
||
/// stroke paints), at the gradient's angle and aspect: every target gets the same vector in
|
||
/// document coordinates, so the ramp runs on across them (#2127).
|
||
fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bool) -> Paint {
|
||
let union = ids
|
||
.iter()
|
||
.filter_map(|id| d.node(*id))
|
||
.filter_map(|n| item_paint_bounds(n, None, fill).or_else(|| n.geometric_bounds()))
|
||
.filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))
|
||
.reduce(|a, b| a.union(b));
|
||
let (Some(union), Paint::Gradient(g)) = (union, &paint) else { return paint };
|
||
// An `aspect` places a radial gradient on the box already; otherwise fit it there.
|
||
match place_paint(&paint, p, Some(union)) {
|
||
Paint::Gradient(placed) if placed.geom.is_some() => Paint::Gradient(placed),
|
||
_ => {
|
||
let geom = vectorcraft_color::GradientGeom::fit(g.gradient.kind, union, g.angle);
|
||
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() }))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
|
||
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
|
||
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
|
||
/// white, and other colors are an error.
|
||
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
|
||
let mut out = vec![];
|
||
for id in ids {
|
||
let Some(n) = d.node(*id) else { continue };
|
||
let NodeKind::Image(im) = &n.kind else { continue };
|
||
// `none` takes a color off whatever the image's pixels have become since.
|
||
if matches!(paint, Paint::None) && n.extras().colorize.is_some() {
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue };
|
||
match paint {
|
||
// Without a color, `none` goes to the image's fill item.
|
||
Paint::None => continue,
|
||
Paint::Solid { .. } => {
|
||
if gray.alpha && !colorizes_transparency(d, paint) {
|
||
return Err(PROCESS_ON_TRANSPARENCY.into());
|
||
}
|
||
}
|
||
_ => continue,
|
||
}
|
||
out.push(*id);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The message for a process color on a grayscale image with an alpha channel.
|
||
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||
|
||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||
/// of `d`, black or white?
|
||
fn colorizes_transparency(d: &Document, paint: &Paint) -> bool {
|
||
match paint {
|
||
Paint::Solid { color, swatch, .. } => swatch.as_deref().and_then(|name| d.swatch(name)).is_some_and(|w| w.spot) || black_or_white(color),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// An error when an image with an alpha channel in `d` (its art, opacity masks, symbols, graph
|
||
/// designs and pattern tiles) is colorized with a color that [`colorizes_transparency`] refuses,
|
||
/// as a swatch edit that turns a spot color into a process color or recolors a process swatch
|
||
/// can leave one.
|
||
pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||
let mut refused = false;
|
||
let mut visit = |n: &Node| {
|
||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
|
||
&& !refused
|
||
&& !colorizes_transparency(d, paint)
|
||
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
|
||
{
|
||
refused = true;
|
||
}
|
||
})
|
||
};
|
||
d.layers
|
||
.iter()
|
||
.chain(d.symbols.iter().map(|s| &s.art))
|
||
.chain(d.graph_designs.iter().map(|g| &g.art))
|
||
.chain(d.patterns.iter().flat_map(|p| &p.art))
|
||
.for_each(|n| visit(n));
|
||
if refused { Err(PROCESS_ON_TRANSPARENCY.into()) } else { Ok(()) }
|
||
}
|
||
|
||
/// [`check_colorized`] after an edit, when the document passed it before the edit (`was_ok`), so
|
||
/// an edit doesn't fail over an image it didn't change.
|
||
pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::Result<(), String> {
|
||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||
}
|
||
|
||
/// Is `e` the refusal of [`check_colorized`]?
|
||
pub(super) fn is_colorize_refusal(e: &EngineError) -> bool {
|
||
e.to_string().contains(PROCESS_ON_TRANSPARENCY)
|
||
}
|
||
|
||
/// Is `c` black or white (white, or black alone at 100%)?
|
||
fn black_or_white(c: &Color) -> bool {
|
||
let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0);
|
||
match *c {
|
||
Color::Gray { k } => none(k) || full(k),
|
||
Color::Cmyk { c, m, y, k } => none(c) && none(m) && none(y) && (none(k) || full(k)),
|
||
Color::Rgb { r, g, b } => [r, g, b].into_iter().all(none) || [r, g, b].into_iter().all(full),
|
||
Color::Lab { .. } => false,
|
||
}
|
||
}
|
||
|
||
/// The points the active tool has picked (its `meshPoints` option: the Mesh tool's or Direct
|
||
/// Selection's) on the gradient meshes among `ids`, in range, each mesh with its points.
|
||
fn picked_mesh_points(s: &Session, ids: &[NodeId]) -> Vec<(NodeId, Vec<usize>)> {
|
||
let o = s.tool_options();
|
||
let (Some(picked), Ok(st)) = (o.get("meshPoints").and_then(Value::as_array), s.doc()) else { return vec![] };
|
||
let mut out: Vec<(NodeId, Vec<usize>)> = vec![];
|
||
for pt in picked {
|
||
let (Some(id), Some(index)) = (pt.get("id").and_then(Value::as_u64).map(NodeId), pt.get("index").and_then(Value::as_u64)) else { continue };
|
||
let Ok(index) = usize::try_from(index) else { continue };
|
||
if !ids.contains(&id) || !matches!(st.doc.node(id).map(|n| &n.kind), Some(NodeKind::Mesh(m)) if index < m.points.len()) {
|
||
continue;
|
||
}
|
||
match out.iter_mut().find(|(m, _)| *m == id) {
|
||
Some((_, v)) => v.push(index),
|
||
None => out.push((id, vec![index])),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The picked gradient mesh points (see [`picked_mesh_points`]) when each of `ids` is a gradient
|
||
/// mesh with points picked: the Transparency panel's opacity is theirs then.
|
||
pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(NodeId, Vec<usize>)>> {
|
||
let v = picked_mesh_points(s, ids);
|
||
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
|
||
}
|
||
|
||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(style);
|
||
}
|
||
style.stroke = paint;
|
||
}
|
||
|
||
/// `paint` as it replaces `old`: a pattern swatch picked over a pattern keeps the placement that
|
||
/// pattern already had, so only the pattern changes (#1970).
|
||
pub(super) fn keep_pattern_xf(paint: &Paint, p: &Value, old: Option<&Paint>) -> Paint {
|
||
match (paint, old) {
|
||
(Paint::Pattern { pattern, xf }, Some(Paint::Pattern { xf: kept, .. })) if p.get("swatch").is_some() && *xf == Affine::IDENTITY => {
|
||
Paint::Pattern { pattern: pattern.clone(), xf: *kept }
|
||
}
|
||
_ => paint.clone(),
|
||
}
|
||
}
|
||
|
||
/// Use the existing 1 pt default when adding paint to an unpainted zero-width stroke.
|
||
pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>, paint: &Paint) {
|
||
if !paint.is_none()
|
||
&& let Some(stroke) = ap.stroke_at_mut(index)
|
||
&& stroke.paint.is_none()
|
||
&& stroke.width == 0.0
|
||
{
|
||
stroke.width = 1.0;
|
||
}
|
||
}
|
||
|
||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = paint_targets(s, p)?;
|
||
if !ids.is_empty() {
|
||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, "Swap Fill and Stroke", |d| {
|
||
for id in &ids {
|
||
let Some(n) = d.node_mut(*id) else { continue };
|
||
if let NodeKind::Text(t) = &mut n.kind {
|
||
for r in &mut t.runs {
|
||
let fill = std::mem::take(&mut r.style.fill);
|
||
r.style.fill = std::mem::take(&mut r.style.stroke);
|
||
set_run_stroke(&mut r.style, fill);
|
||
}
|
||
continue;
|
||
}
|
||
let f = n.appearance.fill_paint();
|
||
let st = n.appearance.stroke_paint();
|
||
n.appearance.set_fill(st);
|
||
restore_unpainted_stroke(&mut n.appearance, None, &f);
|
||
n.appearance.set_stroke(f);
|
||
}
|
||
Ok(super::appearance::count_changed(d, &before, &ids))
|
||
})?;
|
||
}
|
||
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
|
||
s.paint.stroke_width = 1.0;
|
||
}
|
||
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
|
||
ok()
|
||
}
|
||
|
||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = paint_targets(s, p)?;
|
||
if !ids.is_empty() {
|
||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, "Default Fill and Stroke", |d| {
|
||
for id in &ids {
|
||
let Some(n) = d.node_mut(*id) else { continue };
|
||
match &mut n.kind {
|
||
NodeKind::Text(t) => {
|
||
for r in &mut t.runs {
|
||
r.style.fill = Paint::solid(Color::BLACK);
|
||
r.style.stroke = Paint::None;
|
||
}
|
||
}
|
||
_ => n.appearance = Appearance::default_art().into(),
|
||
}
|
||
}
|
||
Ok(super::appearance::count_changed(d, &before, &ids))
|
||
})?;
|
||
}
|
||
super::newart::reset(s);
|
||
ok()
|
||
}
|
||
|
||
/// Invert / Complement (`cmd`, undone as `label`): recolour the active proxy of the targets (or the
|
||
/// default when there are none) and remember the first target's new paint. The Appearance panel's
|
||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||
let stroke = stroke_param(s, p, cmd)?;
|
||
let ids = match super::given_objects(s, p, cmd)? {
|
||
Some(ids) => ids,
|
||
None => super::edit::selected_roots(s)?,
|
||
};
|
||
if ids.is_empty() {
|
||
let def = if stroke { &mut s.paint.stroke } else { &mut s.paint.fill };
|
||
let changed = super::colorcmds::map_paint(def, f);
|
||
let shown = def.clone();
|
||
s.remember_paint(&shown);
|
||
return Ok(json!({ "changed": changed as usize }));
|
||
}
|
||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||
let (r, shown_item) = match item {
|
||
ItemTarget::Item { index, .. } => {
|
||
let ids = item.targets(s, p)?;
|
||
let mut changed = 0;
|
||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||
let paint =
|
||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||
Ok(())
|
||
})?;
|
||
(json!({ "changed": changed }), Some(index))
|
||
}
|
||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||
ItemTarget::Top => {
|
||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||
(super::colorcmds::recolor(s, &q, label, f)?, None)
|
||
}
|
||
};
|
||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||
s.remember_paint(&shown);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "transparency.set";
|
||
let ids = super::opacitymask::transparency_targets(s, p)?;
|
||
if ids.is_empty() {
|
||
return Err(bad(C, "select objects or give ids"));
|
||
}
|
||
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
|
||
// item (a row of that art) doesn't stand for the masked object.
|
||
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
|
||
let item = if editing_mask && p.get("item").is_none() { ItemTarget::Top } else { item_target(s, p, C)? };
|
||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||
q.remove("item");
|
||
q.remove("id");
|
||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||
// The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity
|
||
// themselves, so only the mesh around them fades (#4206, #4170, #4208).
|
||
if matches!(item, ItemTarget::Top)
|
||
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
|
||
&& let Some(points) = picked_mesh_points_of(s, &ids)
|
||
{
|
||
q.remove("opacity");
|
||
let opacity = f64::from(super::opacitymask::percent(o));
|
||
let items: Vec<Value> = points.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect();
|
||
s.execute("object.mesh.setPointColor", &json!({"items": items, "opacity": opacity}))?;
|
||
if q.keys().all(|k| k == "ids") {
|
||
return ok();
|
||
}
|
||
}
|
||
let ItemTarget::Item { index, explicit, aligned } = item else {
|
||
return s.execute("object.setProps", &Value::Object(q));
|
||
};
|
||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
|
||
let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? };
|
||
if explicit {
|
||
let d = &s.doc()?.doc;
|
||
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||
return Err(bad(C, format!("no appearance item {index}")));
|
||
}
|
||
}
|
||
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
|
||
if let Some(b) = &blend
|
||
&& b.as_str().and_then(BlendMode::parse).is_none()
|
||
{
|
||
return Err(bad(C, format!("unknown blend mode {b}")));
|
||
}
|
||
if let Some(k) = q.get("knockout")
|
||
&& vectorcraft_doc::Knockout::from_value(k).is_none()
|
||
{
|
||
return Err(bad(C, format!("unknown knockout state {k}")));
|
||
}
|
||
if opacity.is_some() || blend.is_some() {
|
||
let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect();
|
||
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend, "aligned": aligned }))?;
|
||
}
|
||
if q.keys().all(|k| k == "ids") {
|
||
return ok();
|
||
}
|
||
s.execute("object.setProps", &Value::Object(q))
|
||
}
|