Merge wp/B2

# Conflicts:
#	crates/doc/src/lib.rs
#	crates/engine/src/cmd/style.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/swatches.rs
#	docs/control-protocol.md
This commit is contained in:
bflatastic
2026-10-04 23:37:27 +07:00
24 changed files with 1516 additions and 362 deletions
+38 -19
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
"{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
has_doc,
copy_from
),
@@ -461,18 +461,31 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind {
NodeKind::Text(t) => match t.runs.first() {
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
None => src.appearance.clone(),
},
_ => src.appearance.clone(),
// The appearance and the box its placed gradients are relative to (type: its first run's
// paints, in text space).
let (appearance, src_box) = match &src.kind {
NodeKind::Text(t) if !t.runs.is_empty() => {
let r = &t.runs[0];
(Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds()))
}
_ => (src.appearance.clone(), src.geometric_bounds()),
};
s.paint.fill = appearance.fill_paint();
s.paint.stroke = appearance.stroke_paint();
s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
// `appearance` placed on an object whose box (in its paint space) is `to`.
let placed = |to: Option<vectorcraft_geom::Rect>| {
let mut ap = appearance.clone();
if let (Some(f), Some(t)) = (src_box, to) {
ap.rebase_gradients(f, t);
}
ap
};
let opts = s.eyedropper.with(p);
if opts.appearance {
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
@@ -480,24 +493,30 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
if targets.is_empty() || !(opts.appearance || opts.transparency) {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if opts.transparency {
n.opacity = opacity;
n.blend = blend;
}
if !opts.appearance {
continue;
}
if let NodeKind::Text(t) = &mut n.kind {
let ap = placed(Some(t.local_bounds()));
for r in &mut t.runs {
r.style.fill = appearance.fill_paint();
r.style.stroke = appearance.stroke_paint();
r.style.stroke_width = appearance.stroke_width();
r.style.fill = ap.fill_paint();
r.style.stroke = ap.stroke_paint();
r.style.stroke_width = ap.stroke_width();
}
continue;
}
n.appearance = appearance.clone();
n.appearance = placed(n.geometric_bounds());
}
Ok(())
})?;
+3
View File
@@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
/// The Transparency panel's state ([`Session::transparency_info`]).
pub use opacitymask::TransparencyInfo;
/// What the Eyedropper copies (`Session::eyedropper`).
pub use xform::EyedropperOptions;
+26 -9
View File
@@ -4,11 +4,16 @@
//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays
//! linked while it keeps the style's look; editing its appearance or transparency breaks the link
//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked.
//!
//! Styles keep placed gradients relative to the unit box ([`GraphicStyle::unit_box`]): a new or
//! redefined style stores them relative to its source's bounds, and each object a style is applied
//! to gets them at the same place relative to its own.
use std::collections::HashMap;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::*;
use crate::EngineError;
@@ -20,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it; add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
has_doc,
style_apply
),
@@ -29,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Graphic Style",
["Window", "Graphic Styles"],
None,
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'); links the object. Names are made unique → {name}",
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
has_doc,
style_new
),
@@ -136,7 +141,7 @@ fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) {
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
let mut leaves = vec![];
painted(n, true, &mut leaves);
g.transparency_matches(n) && leaves.iter().all(|l| l.appearance == g.appearance)
g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance))
}
/// The style `n` is linked to (and still looks like).
@@ -170,18 +175,29 @@ fn links(d: &Document) -> HashMap<u32, Vec<NodeId>> {
out
}
/// `ap` with its placed gradients moved from box `from` (an object's bounds) to the unit box.
fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
if let Some(b) = from.filter(|_| ap.has_placed_gradient()) {
ap.rebase_gradients(b, GraphicStyle::UNIT_BOX);
}
ap
}
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
/// and stroke.
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on).
fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone();
if !ap.items.is_empty() {
return ap;
return unitized(ap, n.geometric_bounds());
}
match &n.kind {
NodeKind::Text(t) => {
if let Some(r) = t.runs.first() {
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
// Type paints its characters in text space.
ap = unitized(ap, Some(t.local_bounds()));
}
}
_ => {
@@ -201,7 +217,7 @@ fn captured(n: &Node) -> Appearance {
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
if let Some(n) = d.node_mut(id) {
paint_mut(n, true, &mut |l| l.appearance = g.appearance.clone());
paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned());
g.apply_transparency(n);
n.graphic_style = Some(g.id);
}
@@ -251,12 +267,13 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit("Apply Graphic Style", |d, _| {
if add {
let ap = d.graphic_styles[i].appearance.clone();
let g = d.graphic_styles[i].clone();
for id in &ids {
if let Some(n) = d.node_mut(*id) {
paint_mut(n, true, &mut |l| {
l.appearance.items.extend(ap.items.iter().cloned());
l.appearance.effects.extend(ap.effects.iter().cloned());
let ap = g.appearance_on(l).into_owned();
l.appearance.items.extend(ap.items);
l.appearance.effects.extend(ap.effects);
});
n.graphic_style = None;
}
+57 -4
View File
@@ -3,6 +3,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@@ -26,10 +27,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Sample Color",
[],
None,
"{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)",
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
has_doc,
sample_color
),
cmd!(
"eyedropper.setOptions",
"Eyedropper Options",
[],
None,
"{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}",
always,
eyedropper_options
),
cmd!(
"artboard.move",
"Move Artboard",
@@ -161,14 +171,57 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- eyedropper ----------
/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EyedropperOptions {
/// The fill, stroke and effects (the appearance stack; type: its run paints).
pub appearance: bool,
/// The opacity and blend mode.
pub transparency: bool,
}
impl Default for EyedropperOptions {
fn default() -> Self {
Self { appearance: true, transparency: true }
}
}
impl EyedropperOptions {
/// These options with any `appearance` / `transparency` booleans in `p` applied.
pub(crate) fn with(mut self, p: &Value) -> Self {
self.appearance = bool_or(p, "appearance", self.appearance);
self.transparency = bool_or(p, "transparency", self.transparency);
self
}
}
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
s.eyedropper = s.eyedropper.with(p);
Ok(serde_json::to_value(s.eyedropper).unwrap_or_default())
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.sampleColor";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?;
let mut q = json!({ "color": c });
let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
let (id, mut q) = match p.get("stop") {
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
let (fill, stroke_paint) = s.proxy_paints();
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
return Err(bad(C, "the paint behind the proxy is not a gradient"));
};
let mut stops = g.gradient.stops;
let n = stops.len();
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color;
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
}
};
if let Some(ids) = p.get("ids") {
q["ids"] = ids.clone();
}
let id = if stroke { "paint.setStroke" } else { "paint.setFill" };
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.run)(s, &q)
}