From 61ddc1ce348cec5807652966b07cd752533df96c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:22:49 +0700 Subject: [PATCH 1/7] =?UTF-8?q?M3.14:=20lossless,=20validated=20gradient?= =?UTF-8?q?=20params=20=E2=80=94=20paint.setFill/setStroke,=20swatch.new?= =?UTF-8?q?=20and=20appearance.setItem=20keep=20kind=20(freeform=20too),?= =?UTF-8?q?=20midpoints,=20stop=20opacity,=20aspect,=20vector=20and=20swat?= =?UTF-8?q?ch=20link;=20a=20shared=20parse=5Fstops=20rejects=20fewer=20tha?= =?UTF-8?q?n=20two=20or=20malformed=20stops,=20clamps=20offsets=20and=20no?= =?UTF-8?q?rmalises=200..100=20opacity;=20an=20aspect=20without=20a=20vect?= =?UTF-8?q?or=20places=20the=20gradient=20on=20each=20object's=20(stroke-i?= =?UTF-8?q?nflated)=20bounds;=20panels=20write=20gradients=20through=20one?= =?UTF-8?q?=20params=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 4 + crates/doc/src/appearance.rs | 5 + crates/engine/src/cmd/appearance.rs | 12 ++- crates/engine/src/cmd/gradient.rs | 112 ++++++++++++++++------ crates/engine/src/cmd/paint.rs | 33 ++----- crates/engine/src/cmd/swatch.rs | 10 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_gradient.rs | 133 ++++++++++++++++++++++++++ crates/tools/src/lib.rs | 1 + crates/tools/src/params.rs | 62 ++++++++++++ crates/ui-egui/src/panels/gradient.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 17 +--- 12 files changed, 321 insertions(+), 78 deletions(-) create mode 100644 crates/engine/src/tests_gradient.rs create mode 100644 crates/tools/src/params.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 3554ba16..dd45c9bf 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -15,6 +15,10 @@ pub enum GradientKind { } impl GradientKind { + /// Parse a kind name (`linear`, `radial`, `freeform`; any case). + pub fn parse(s: &str) -> Option { + [GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s)) + } pub fn label(self) -> &'static str { match self { GradientKind::Linear => "Linear", diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 91ef1b84..fe029fe1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -213,6 +213,11 @@ impl StrokeLayer { effects: vec![], } } + /// The box an unplaced gradient on this stroke fits to: the object's geometric bounds grown by + /// half the weight (as the renderers resolve it). + pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { + geometric.inflate(self.width / 2.0, self.width / 2.0) + } } /// One entry of the appearance stack. diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 10f24bad..f3e6f506 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack", + "{index, opacity?, blend?, visible?, weight?, color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of the selection's appearance stack", has_selection, set_item ), @@ -101,18 +101,20 @@ fn set_item(s: &mut Session, p: &Value) -> Result { s.edit("Appearance", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; + let bounds = n.geometric_bounds(); let Some(item) = n.appearance.items.get_mut(idx) else { continue }; - let (pp, op, bl, vis) = match item { - AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible), + let (pp, op, bl, vis, bounds) = match item { + AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible, bounds), AppearanceItem::Stroke(st) => { if let Some(w) = p.get("weight").and_then(Value::as_f64) { st.width = w.max(0.0); } - (&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible) + let bounds = bounds.map(|b| st.paint_bounds(b)); + (&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible, bounds) } }; if let Some(pa) = &paint { - *pp = pa.clone(); + *pp = super::gradient::place_paint(pa, p, bounds); } if let Some(o) = p.get("opacity").and_then(Value::as_f64) { *op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 01b0d0e7..f41f077d 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -31,6 +31,84 @@ pub fn specs() -> Vec { ] } +type Parsed = std::result::Result; + +/// Parse a kind name. +fn parse_kind(k: &str) -> Parsed { + GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)")) +} + +/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid +/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the +/// diamond's range. The result is sorted by offset. +pub(crate) fn parse_stops(v: &Value) -> Parsed> { + let arr = v.as_array().ok_or("`stops` must be an array")?; + if arr.len() < 2 { + return Err("a gradient needs at least two stops".into()); + } + let mut out = arr + .iter() + .enumerate() + .map(|(i, st)| { + let num = |k: &str| st.get(k).map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number"))).transpose(); + let offset = num("offset")?.ok_or_else(|| format!("stop {i} needs `offset`"))?; + let color = st.get("color").and_then(color_value).ok_or_else(|| format!("stop {i} needs a valid `color`"))?; + let opacity = num("opacity")?.map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); + Ok(GradientStop { + offset: offset.clamp(0.0, 1.0) as f32, + color, + opacity: opacity.clamp(0.0, 1.0) as f32, + midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32, + }) + }) + .collect::>>()?; + out.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + Ok(out) +} + +/// Parse a gradient paint object (the `gradient` param of `paint.setFill`). Lossless for everything +/// `vectorcraft_tools::params::gradient_params` writes. +pub(crate) fn parse_gradient(g: &Value) -> Parsed { + if !g.is_object() { + return Err("`gradient` must be an object".into()); + } + let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default(); + let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops); + let mut gp = GradientPaint::new(Gradient { kind, stops }); + gp.angle = f64_or(g, "angle", 0.0); + gp.swatch = str_param(g, "swatch").map(str::to_string); + let aspect = aspect_param(g)?; + let point = |k: &str| g.get(k).map(|_| point_param(g, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose(); + match (point("start")?, point("end")?) { + (Some(start), Some(end)) => { + let geom = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0) }; + gp.angle = geom.angle_deg(); + gp.geom = Some(geom); + } + (None, None) => {} + _ => return Err("give both `start` and `end` (or neither)".into()), + } + Ok(gp) +} + +/// The `aspect` param (a percentage) as a ratio. +fn aspect_param(p: &Value) -> Parsed> { + p.get("aspect") + .map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string())) + .transpose() +} + +/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is +/// placed on the bounds so the aspect sticks. +pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { + match (paint, p.get("gradient").and_then(|g| g.get("aspect"))) { + (Paint::Gradient(g), Some(a)) if g.geom.is_none() => { + apply_gradient_edit(paint, &json!({ "aspect": a }), bounds).unwrap_or_else(|_| paint.clone()) + } + _ => paint.clone(), + } +} + /// Apply the gradient edits in `p` to `paint` (pure; unit-tested). pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { let mut gp = match paint { @@ -38,37 +116,14 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option GradientPaint::new(Gradient::default()), }; if let Some(k) = str_param(p, "kind") { - let kind = match k.to_ascii_lowercase().as_str() { - "linear" => GradientKind::Linear, - "radial" => GradientKind::Radial, - "freeform" => GradientKind::Freeform, - _ => return Err(format!("unknown gradient kind `{k}`")), - }; + let kind = parse_kind(k)?; if kind != gp.gradient.kind { gp.gradient.kind = kind; gp.geom = None; } } if let Some(stops) = p.get("stops") { - let arr = stops.as_array().ok_or("`stops` must be an array")?; - let mut out = Vec::with_capacity(arr.len()); - for st in arr { - let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?; - let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?; - let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); - let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5); - out.push(GradientStop { - offset: offset.clamp(0.0, 1.0) as f32, - color, - opacity: opacity.clamp(0.0, 1.0) as f32, - midpoint: midpoint.clamp(0.13, 0.87) as f32, - }); - } - if out.len() < 2 { - return Err("a gradient needs at least two stops".into()); - } - gp.gradient.stops = out; - gp.gradient.sort(); + gp.gradient.stops = parse_stops(stops)?; gp.swatch = None; } if bool_or(p, "reverse", false) { @@ -96,8 +151,7 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option Result { } let b = n.geometric_bounds(); let ap = &mut n.appearance; - let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() }; + let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) }; match apply_gradient_edit(&cur, p, b) { Ok(np) => { if stroke { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 712d0af5..5af3ab35 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,9 +1,10 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; +use super::gradient::place_paint; use super::*; use crate::EngineError; @@ -14,7 +15,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector; both or neither), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default", has_doc, |s, p| set_paint(s, p, true) ), @@ -62,27 +63,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return Ok(Some(paint)); } if let Some(g) = p.get("gradient") { - let kind = match str_param(g, "kind") { - Some("radial") => GradientKind::Radial, - _ => GradientKind::Linear, - }; - let mut grad = Gradient { kind, ..Default::default() }; - if let Some(stops) = g.get("stops").and_then(Value::as_array) { - grad.stops = stops - .iter() - .filter_map(|st| { - Some(vectorcraft_color::GradientStop { - offset: st.get("offset")?.as_f64()? as f32, - color: color_value(st.get("color")?)?, - opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32, - midpoint: 0.5, - }) - }) - .collect(); - grad.sort(); - } - let mut gp = GradientPaint::new(grad); - gp.angle = f64_or(g, "angle", 0.0); + let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { @@ -120,10 +101,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { } continue; } + let b = n.geometric_bounds(); if fill { - n.appearance.set_fill(paint.clone()); + n.appearance.set_fill(place_paint(&paint, p, b)); } else { n.appearance.set_stroke(paint.clone()); + if let Some(st) = n.appearance.stroke_mut() { + st.paint = place_paint(&paint, p, b.map(|b| st.paint_bounds(b))); + } } } Ok(()) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7b5793d9..eea07861 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -10,7 +10,15 @@ use crate::EngineError; pub fn specs() -> Vec { vec![ - cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new), + cmd!( + "swatch.new", + "New Swatch", + ["Window", "Swatches"], + None, + "{name?, color?|gradient? (as paint.setFill), global?} (default: current fill)", + has_doc, + swatch_new + ), cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), cmd!( "swatch.newGroup", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..b435ad68 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -721,6 +721,8 @@ mod tests_draw2; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_gradient; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_menucmds; diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs new file mode 100644 index 00000000..00951b98 --- /dev/null +++ b/crates/engine/src/tests_gradient.rs @@ -0,0 +1,133 @@ +//! Gradients through commands: lossless, validated params (M3.14). + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_geom::Point; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap() +} + +fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { + match node(s, id).appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } +} + +/// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector. +fn three_stops() -> GradientPaint { + let stop = |offset, color, opacity, midpoint| GradientStop { offset, color, opacity, midpoint }; + let mut g = GradientPaint::new(Gradient { + kind: GradientKind::Freeform, + stops: vec![ + stop(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3), + stop(0.4, Color::cmyk(0.1, 0.2, 0.3, 0.4), 0.5, 0.5), + stop(1.0, Color::gray(0.25), 0.75, 0.5), + ], + }); + g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5 }); + g.angle = g.geom.unwrap().angle_deg(); + g +} + +fn gradient_param(g: &GradientPaint) -> Value { + json!({ "gradient": vectorcraft_tools::params::gradient_params(g) }) +} + +#[test] +fn gradient_params_round_trip_through_fill_and_swatches() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let g = three_stops(); + s.execute("paint.setFill", &gradient_param(&g)).unwrap(); + let got = fill_gradient(&s, id); + assert_eq!(got.gradient.kind, GradientKind::Freeform); + assert_eq!(got.gradient.stops, g.gradient.stops); + let (a, b) = (got.geom.unwrap(), g.geom.unwrap()); + assert!(a.start.distance(b.start) < 1e-9 && a.end.distance(b.end) < 1e-9 && (a.aspect - b.aspect).abs() < 1e-9, "{a:?}"); + // The default proxy keeps it too, and saving it as a swatch is lossless. + assert_eq!(s.paint.fill, Paint::Gradient(Box::new(got.clone()))); + let mut q = gradient_param(&g); + q["name"] = json!("Three"); + s.execute("swatch.new", &q).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatch("Three").unwrap().paint, Paint::Gradient(Box::new(got))); +} + +#[test] +fn bad_gradients_are_rejected_without_editing() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let before = node(&s, id).appearance.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [ + json!({"stops": [{"offset": 0, "color": "#ff0000"}]}), + json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1}]}), + json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"color": "#000000"}]}), + json!({"stops": [{"offset": "a", "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}), + json!({"stops": "red"}), + json!({"kind": "conic"}), + json!({"start": [0, 0]}), + json!({"aspect": "wide"}), + ] { + assert!(s.execute("paint.setFill", &json!({ "gradient": bad })).is_err(), "accepted {bad}"); + // `start` isn't a paint.editGradient param (it would just apply the gradient). + if bad.get("start").is_none() { + assert!(s.execute("paint.editGradient", &bad).is_err(), "editGradient accepted {bad}"); + } + } + assert_eq!(node(&s, id).appearance, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn stop_opacity_and_offsets_are_normalised() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute( + "paint.setFill", + &json!({"gradient": {"stops": [{"offset": 1.5, "color": "#000000", "opacity": 50}, {"offset": -1, "color": "#ffffff", "opacity": 0.25, "midpoint": 0.99}]}}), + ) + .unwrap(); + let g = fill_gradient(&s, id); + // Sorted, offsets clamped, 0..100 opacity normalised, midpoint clamped to the diamond's range. + let st = &g.gradient.stops; + assert_eq!((st[0].offset, st[0].opacity, st[0].midpoint), (0.0, 0.25, 0.87)); + assert_eq!((st[1].offset, st[1].opacity), (1.0, 0.5)); + assert!(g.geom.is_none()); +} + +#[test] +fn geometry_and_aspect_in_one_call() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 150], "end": [260, 150], "aspect": 40}})).unwrap(); + let geom = fill_gradient(&s, id).geom.unwrap(); + assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(260.0, 150.0))); + assert!((geom.aspect - 0.4).abs() < 1e-9); + // An aspect without a vector places the gradient on the object's bounds so the aspect sticks. + s.execute("paint.setStroke", &json!({"gradient": {"kind": "radial", "aspect": 25}})).unwrap(); + let Paint::Gradient(sg) = node(&s, id).appearance.stroke_paint() else { panic!() }; + let geom = sg.geom.unwrap(); + assert_eq!(geom.start, Point::new(200.0, 150.0)); + assert!((geom.aspect - 0.25).abs() < 1e-9 && (geom.length() - 100.5).abs() < 1e-9, "fits the stroke-inflated bounds: {geom:?}"); + // appearance.setItem takes the same gradient object. + let g = three_stops(); + let mut q = gradient_param(&g); + q["index"] = json!(0); + s.execute("appearance.setItem", &q).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient, g.gradient); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 440ee67c..f1a481f7 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -17,6 +17,7 @@ pub mod draw2; pub mod extra; pub mod guides; pub mod meshblend; +pub mod params; pub mod pen; pub mod select; pub mod shape; diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs new file mode 100644 index 00000000..faee9a18 --- /dev/null +++ b/crates/tools/src/params.rs @@ -0,0 +1,62 @@ +//! Command params written by tools and panels: the inverse of the engine's paint parsers, so a +//! paint read from the document and sent back through a command arrives unchanged. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, GradientPaint, GradientStop}; + +/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`. +pub fn color_json(c: &Color) -> Value { + match *c { + Color::Rgb { r, g, b } => json!([r, g, b]), + Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}), + Color::Gray { k } => json!({"gray": k}), + } +} + +/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint. +pub fn stops_json(stops: &[GradientStop]) -> Value { + Value::Array( + stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(), + ) +} + +/// A gradient paint as the `gradient` param of `paint.setFill` / `swatch.new`: kind, stops (with +/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%). +pub fn gradient_params(g: &GradientPaint) -> Value { + let mut v = json!({ + "kind": g.gradient.kind.label().to_lowercase(), + "angle": g.angle, + "stops": stops_json(&g.gradient.stops), + }); + if let Some(geom) = g.geom { + v["start"] = json!([geom.start.x, geom.start.y]); + v["end"] = json!([geom.end.x, geom.end.y]); + v["aspect"] = json!(geom.aspect * 100.0); + } + if let Some(s) = &g.swatch { + v["swatch"] = json!(s); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::{Gradient, GradientGeom, GradientKind}; + use vectorcraft_geom::Point; + + #[test] + fn gradient_params_carry_placement_only_when_placed() { + let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Default::default() }); + let v = gradient_params(&g); + assert_eq!(v["kind"], "radial"); + assert!(v.get("start").is_none() && v.get("aspect").is_none()); + assert_eq!(v["stops"][0]["midpoint"], json!(0.5)); + g.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5 }); + g.swatch = Some("Sky".into()); + let v = gradient_params(&g); + assert_eq!((v["start"].clone(), v["end"].clone(), v["aspect"].clone()), (json!([1.0, 2.0]), json!([3.0, 4.0]), json!(50.0))); + assert_eq!(v["swatch"], "Sky"); + assert_eq!(color_json(&Color::gray(0.25)), json!({"gray": 0.25})); + } +} diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index a789e066..d336afd8 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -6,7 +6,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; -use super::{active_paint, color_json, live_run, pstate, set_pstate}; +use super::{active_paint, live_run, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, Live, menu_item}; use crate::{VectorcraftApp, icons}; @@ -88,11 +88,7 @@ pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option Value { - Value::Array( - stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(), - ) -} +pub use vectorcraft_tools::params::stops_json; // ---------- UI ---------- diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..523e007d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -266,25 +266,15 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { } /// A colour as command JSON, keeping its model. -pub(crate) fn color_json(c: &Color) -> Value { - match *c { - Color::Rgb { r, g, b } => json!([r, g, b]), - Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}), - Color::Gray { k } => json!({"gray": k}), - } -} +pub(crate) use vectorcraft_tools::params::color_json; -/// Paint as command params (`{color}`, `{none}` or `{gradient}`). +/// Paint as command params (`{color}`, `{none}` or `{gradient}`, lossless for gradients). pub(crate) fn paint_params(p: &Paint) -> Value { match p { Paint::None => json!({"none": true}), Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}), Paint::Solid { color, .. } => json!({"color": color_json(color)}), - Paint::Gradient(g) => json!({"gradient": { - "kind": g.gradient.kind.label().to_lowercase(), - "angle": g.angle, - "stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::>() - }}), + Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}), Paint::Pattern { .. } => json!({"none": true}), } } @@ -321,5 +311,6 @@ mod tests { let p = paint_params(&g); assert_eq!(p["gradient"]["kind"], "linear"); assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2); + assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5)); } } From 81f86a9acc569123bdaa08e86d33452a03687cbe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:31:54 +0700 Subject: [PATCH 2/7] =?UTF-8?q?M3.15:=20gradients=20follow=20every=20trans?= =?UTF-8?q?form=20=E2=80=94=20rotate,=20reflect,=20shear,=20non-uniform=20?= =?UTF-8?q?scale=20and=20Free=20Distort/warps=20pin=20an=20unplaced=20grad?= =?UTF-8?q?ient=20to=20its=20fit=20first=20(fills=20on=20the=20bounds,=20s?= =?UTF-8?q?trokes=20on=20the=20stroke-inflated=20box;=20moves=20and=20unif?= =?UTF-8?q?orm=20scales=20keep=20refitting);=20linear=20vectors=20keep=20t?= =?UTF-8?q?heir=20isolines=20and=20radial=20gradients=20map=20their=20elli?= =?UTF-8?q?pse=20(principal=20axes=20give=20the=20new=20end=20and=20aspect?= =?UTF-8?q?);=20the=20radial=20angle=20places=20the=20vector=20before=20th?= =?UTF-8?q?e=20gradient=20is=20placed;=20the=20eyedropper=20samples=20radi?= =?UTF-8?q?als=20honouring=20aspect;=20SVG=20import=20shares=20the=20mappi?= =?UTF-8?q?ng;=20warped=20text,=20images=20and=20symbols=20no=20longer=20m?= =?UTF-8?q?ap=20their=20gradients=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 164 +++++++++++++++++++++++++-- crates/doc/src/appearance.rs | 48 ++++++++ crates/doc/src/node.rs | 55 ++++++--- crates/engine/src/cmd/xform.rs | 25 ++-- crates/engine/src/tests_gradient.rs | 43 +++++++ crates/svg/src/export.rs | 2 +- crates/svg/src/import.rs | 30 ++--- crates/tools/src/distort.rs | 50 ++++---- crates/tools/src/xform/eyedropper.rs | 24 ++-- 9 files changed, 343 insertions(+), 98 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index dd45c9bf..46ef0440 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -131,27 +131,83 @@ fn one64() -> f64 { } impl GradientGeom { - /// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`, - /// radial is centred with radius = half the larger dimension. + /// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`, + /// radial is centred with radius = half the larger dimension and its vector at `angle_deg`. pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { let c = b.center(); + let a = angle_deg.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); match kind { - GradientKind::Radial => { - let r = b.width().max(b.height()) / 2.0; - Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 } - } + GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 }, _ => { - let a = angle_deg.to_radians(); - let d = Vec2::new(a.cos(), -a.sin()); // Project the box corners onto the direction to cover the whole box. let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; Self { start: c - d * half, end: c + d * half, aspect: 1.0 } } } } - pub fn transform(&mut self, a: Affine) { - self.start = a * self.start; - self.end = a * self.end; + + /// Map the placement through `a` so the gradient follows its object exactly. + /// + /// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the + /// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse + /// (the radius along the vector, `aspect` × the radius across it); its principal axes give the + /// new end (on the axis nearest the mapped vector) and aspect. + pub fn transform(&mut self, a: Affine, kind: GradientKind) { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y); + let perp = |v: Vec2| Vec2::new(-v.y, v.x); + let start = a * self.start; + let u = self.end - self.start; + if u.hypot2() < 1e-18 { + self.start = start; + self.end = start; + return; + } + if kind != GradientKind::Radial { + let far = a * self.end; + let nrm = perp(lin(perp(u))); + self.start = start; + self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far }; + return; + } + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + // Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii. + let p = lu.x * lu.x + lw.x * lw.x; + let q = lu.y * lu.y + lw.y * lw.y; + let r = lu.x * lu.y + lw.x * lw.y; + let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt(); + let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt(); + let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt(); + let (axis, len, other) = if disc <= 1e-12 * (p + q) { + // A circle: every direction is an axis, so keep the mapped vector's. + (lu / lu.hypot().max(1e-300), s1, s2) + } else { + let th = 0.5 * (2.0 * r).atan2(p - q); + let e1 = Vec2::new(th.cos(), th.sin()); + let e2 = perp(e1); + if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) } + }; + let axis = if axis.dot(lu) < 0.0 { -axis } else { axis }; + self.start = start; + self.end = start + axis * len; + self.aspect = if len > 1e-12 { other / len } else { 1.0 }; + } + + /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection + /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial. + pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { + let u = self.end - self.start; + let l2 = u.hypot2(); + if l2 < 1e-18 { + return 0.0; + } + let d = p - self.start; + let along = d.dot(u) / l2; + match kind { + GradientKind::Radial => along.hypot(d.cross(u) / (l2 * self.aspect.max(1e-9))), + _ => along, + } } pub fn angle_deg(&self) -> f64 { let v = self.end - self.start; @@ -183,6 +239,19 @@ impl GradientPaint { pub fn resolve(&self, bounds: Rect) -> GradientGeom { self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle)) } + /// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms + /// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps). + pub fn pin(&mut self, bounds: Rect) { + if self.geom.is_none() { + self.geom = Some(self.resolve(bounds)); + } + } + /// Map a placed gradient through `a` (see [`GradientGeom::transform`]). + pub fn transform(&mut self, a: Affine) { + if let Some(g) = &mut self.geom { + g.transform(a, self.gradient.kind); + } + } } #[cfg(test)] @@ -225,4 +294,77 @@ mod tests { assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); } + + fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 + } + + fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom { + GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 } + } + + #[test] + fn rotate_90_gives_vertical_vector() { + let mut g = linear((0.0, 0.0), (100.0, 0.0)); + g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}"); + assert!((g.angle_deg() + 90.0).abs() < 1e-9); + } + + #[test] + fn reflect_swaps_start_and_end() { + let mut g = linear((0.0, 25.0), (100.0, 25.0)); + // Reflect across the vertical axis through x = 50. + let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0)); + g.transform(flip, GradientKind::Linear); + assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}"); + } + + #[test] + fn non_uniform_scale_turns_a_circle_into_an_ellipse() { + let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0 }; + g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}"); + assert!((g.aspect - 0.5).abs() < 1e-9); + // A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long. + let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0 }; + v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial); + assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}"); + } + + #[test] + fn samples_follow_shear_and_rotation() { + let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4); + for kind in [GradientKind::Linear, GradientKind::Radial] { + let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6 }; + let mut m = g; + m.transform(shear, kind); + for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] { + let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p)); + assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}"); + } + } + } + + #[test] + fn radial_fit_uses_the_angle_and_param_honours_aspect() { + let b = Rect::new(0.0, 0.0, 100.0, 60.0); + let g = GradientGeom::fit(GradientKind::Radial, b, 90.0); + assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}"); + let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5 }; + assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12); + assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12); + assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); + } + + #[test] + fn pin_fixes_the_fit_once() { + let mut p = GradientPaint::new(Gradient::default()); + p.angle = 90.0; + let b = Rect::new(0.0, 0.0, 100.0, 50.0); + p.pin(b); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + p.pin(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0))); + } } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index fe029fe1..ca773897 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -321,6 +321,54 @@ impl Appearance { } } } + /// Gradient paints of the fills and strokes. + fn gradients_mut(&mut self) -> impl Iterator { + self.items.iter_mut().filter_map(|i| match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&mut **g), + _ => None, + }) + } + /// Does a fill or stroke carry a gradient that is fitted to the bounds on each render? + pub fn has_unplaced_gradient(&self) -> bool { + self.items.iter().any(|i| match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) + | AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(), + _ => false, + }) + } + /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit + /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. + pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { + for i in &mut self.items { + match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.pin(bounds), + AppearanceItem::Stroke(s) => { + let b = s.paint_bounds(bounds); + if let Paint::Gradient(g) = &mut s.paint { + g.pin(b); + } + } + _ => {} + } + } + } + /// Map placed gradients through `a`. + pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { + for g in self.gradients_mut() { + g.transform(a); + } + } + /// Map placed gradients through a warp, given its local affine approximation at a point (taken + /// at each gradient's centre: the start of a radial, the middle of a linear vector). + pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { + for g in self.gradients_mut() { + if let Some(geom) = g.geom { + let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) }; + g.transform(near(c)); + } + } + } } #[cfg(test)] diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929..8c3b98e9 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -448,13 +448,17 @@ impl Node { /// Apply an affine transform to the geometry (and gradients) of this node and its descendants. /// `scale_strokes` also scales stroke weights by the transform's mean scale. pub fn transform(&mut self, a: Affine, scale_strokes: bool) { + // Refitting an unplaced gradient only reproduces moves and uniform scales. + if !keeps_gradient_fit(a) { + self.pin_gradients(); + } if scale_strokes { let det = a.determinant().abs().sqrt(); if (det - 1.0).abs() > 1e-9 { self.appearance.scale_strokes(det); } } - transform_paints(&mut self.appearance, a); + self.appearance.transform_gradients(a); match &mut self.kind { NodeKind::Path { path, live, .. } => { path.transform(a); @@ -516,24 +520,24 @@ impl Node { self.walk(&mut |_| n += 1); n } -} - -fn transform_paints(ap: &mut Appearance, a: Affine) { - use crate::appearance::AppearanceItem; - use vectorcraft_color::Paint; - for it in &mut ap.items { - let p = match it { - AppearanceItem::Fill(f) => &mut f.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom + /// Fix this node's unplaced gradients to their fit on its current bounds (before a transform + /// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed. + pub fn pin_gradients(&mut self) { + if self.appearance.has_unplaced_gradient() + && let Some(b) = self.geometric_bounds() { - geom.transform(a); + self.appearance.pin_gradients(b); } } } +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? +fn keeps_gradient_fit(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let eps = 1e-12 * m0.abs().max(1.0); + m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps +} + #[cfg(test)] mod tests { use super::*; @@ -555,6 +559,29 @@ mod tests { assert_eq!(child.appearance.stroke_width(), 2.0); } + #[test] + fn unplaced_gradients_pin_only_when_a_refit_would_differ() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; + let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); + let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { + (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), + _ => unreachable!(), + }; + let mut n = fresh(); + n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); + assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); + let mut n = fresh(); + n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); + let (f, s) = geoms(&n); + // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. + let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + let f = f.unwrap(); + assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); + assert!((s.unwrap().length() - 102.0).abs() < 1e-9); + } + #[test] fn display_names() { let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index a0d45261..90e5892c 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::selected_roots; @@ -91,23 +90,10 @@ impl Projective { } } -fn warp_paints(ap: &mut Appearance, pr: &Projective) { - for it in &mut ap.items { - let p = match it { - vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom - { - geom.start = pr.apply(geom.start); - geom.end = pr.apply(geom.end); - } - } -} - +/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine +/// approximation at their centre. fn warp_node(n: &mut Node, pr: &Projective) { - warp_paints(&mut n.appearance, pr); + n.pin_gradients(); match &mut n.kind { NodeKind::Path { path, live, .. } => { *live = None; @@ -125,11 +111,14 @@ fn warp_node(n: &mut Node, pr: &Projective) { } } _ => { + // No editable points: the affine approximation (which maps the gradients too). if let Some(b) = n.geometric_bounds() { n.transform(pr.affine_near(b.center()), false); } + return; } } + n.appearance.warp_gradients(&|p| pr.affine_near(p)); } fn distort(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 00951b98..750b93af 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -131,3 +131,46 @@ fn geometry_and_aspect_in_one_call() { s.execute("appearance.setItem", &q).unwrap(); assert_eq!(fill_gradient(&s, id).gradient, g.gradient); } + +// ---------- M3.15: gradients follow every transform ---------- + +fn fill_geom(s: &Session, id: NodeId) -> GradientGeom { + fill_gradient(s, id).geom.expect("placed") +} + +#[test] +fn rotate_reflect_and_scale_carry_unplaced_gradients() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert!(fill_gradient(&s, id).geom.is_none(), "a move keeps refitting"); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + let g = fill_geom(&s, id); + assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}"); + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + let r = fill_geom(&s, id); + // The vertical vector is centred on the object, so the reflection swaps its ends. + assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}"); + + // A circle's radial gradient becomes an ellipse under a non-uniform scale. + let c = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(); + let c = NodeId(c["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let e = fill_geom(&s, c); + assert!((e.aspect - 0.5).abs() < 1e-9 && (e.length() - 100.0).abs() < 1e-9, "{e:?}"); +} + +#[test] +fn free_distort_maps_gradients_once() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + // An affine distort (a sheared parallelogram) maps the gradient exactly like object.transform. + s.execute("object.distort", &json!({"corners": [[20, 0], [120, 0], [100, 100], [0, 100]]})).unwrap(); + let g = fill_geom(&s, id); + let mut want = GradientGeom::fit(GradientKind::Linear, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear); + assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ba71ddca..96947f11 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -322,7 +322,7 @@ impl Writer<'_> { fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { let mut geom = g.resolve(bounds); - geom.transform(self.xf); + geom.transform(self.xf, g.gradient.kind); let (s, e) = (geom.start, geom.end); let head = match g.gradient.kind { GradientKind::Radial => { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8bc4dcee..b647db4a 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -70,11 +70,6 @@ fn aff(t: usvg::Transform) -> Affine { Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) } -/// Linear part of `m` applied to `v`. -fn lin(m: Affine, v: Vec2) -> Vec2 { - (m * v.to_point()) - (m * Point::ZERO) -} - fn is_plain(g: &usvg::Group) -> bool { g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal } @@ -277,17 +272,10 @@ impl Importer { if lg.spread_method() != usvg::SpreadMethod::Pad { self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); } - let t = m * aff(lg.transform()); - let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64)); - let start = t * p1; - let far = t * p2; - // Isolines (perpendicular to the vector in gradient space) stay parallel under an - // affine map; the document gradient vector is the normal to their image. - let dir = p2 - p1; - let iso = lin(t, Vec2::new(-dir.y, dir.x)); - let nrm = Vec2::new(-iso.y, iso.x); - let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far }; - gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 }) + let mut geom = + GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0 }; + geom.transform(m * aff(lg.transform()), GradientKind::Linear); + gp(GradientKind::Linear, stops(lg), geom) } usvg::Paint::RadialGradient(rg) => { if rg.spread_method() != usvg::SpreadMethod::Pad { @@ -296,14 +284,10 @@ impl Importer { if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 { self.warn(format!("gradient '{}': focal point ignored", rg.id())); } - let t = m * aff(rg.transform()); - let r = rg.r().get() as f64; let c = Point::new(rg.cx() as f64, rg.cy() as f64); - let vx = lin(t, Vec2::new(r, 0.0)); - let vy = lin(t, Vec2::new(0.0, r)); - let start = t * c; - let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 }; - gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect }) + let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 }; + geom.transform(m * aff(rg.transform()), GradientKind::Radial); + gp(GradientKind::Radial, stops(rg), geom) } usvg::Paint::Pattern(pt) => { self.warn(format!("pattern '{}' not supported; painted as none", pt.id())); diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index ab22cdba..0eb1d57c 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -19,8 +19,7 @@ mod width; use std::sync::Arc; -use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind}; +use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Vec2}; use crate::Tool; @@ -42,11 +41,15 @@ pub fn create(id: &str) -> Option> { }) } -/// Apply a point map to every anchor and handle of `n` and its descendants. Gradient vectors are -/// mapped too; objects without editable points (text, images, symbols, meshes, live objects) get -/// the map's affine approximation at their centre. Live shapes become plain paths. +/// Apply a point map to every anchor and handle of `n` and its descendants. Gradients (pinned +/// first if unplaced) follow the map's affine approximation at their centre; objects without +/// editable points (text, images, symbols, meshes, live objects) get the approximation at their +/// centre. Live shapes become plain paths. pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { - warp_paints(&mut n.appearance, f); + let bounds = n.geometric_bounds(); + // Finite-difference step: 5% of the object (at least half a point). + let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5)); + n.pin_gradients(); match &mut n.kind { NodeKind::Path { path, live, .. } => { *live = None; @@ -67,27 +70,13 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { } } _ => { - if let Some(b) = n.geometric_bounds() { - let a = affine_near(f, b.center(), (b.width().max(b.height()) * 0.05).max(0.5)); - n.transform(a, false); + if let Some(b) = bounds { + n.transform(affine_near(f, b.center(), eps), false); } + return; } } -} - -fn warp_paints(ap: &mut Appearance, f: &dyn Fn(Point) -> Point) { - for it in &mut ap.items { - let p = match it { - AppearanceItem::Fill(fl) => &mut fl.paint, - AppearanceItem::Stroke(s) => &mut s.paint, - }; - if let Paint::Gradient(g) = p - && let Some(geom) = &mut g.geom - { - geom.start = f(geom.start); - geom.end = f(geom.end); - } - } + n.appearance.warp_gradients(&|p| affine_near(f, p, eps)); } /// The affine map that best matches `f` near `p` (finite differences with step `eps`). @@ -136,6 +125,8 @@ pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> vector #[cfg(test)] mod tests { use super::*; + use vectorcraft_color::{Gradient, GradientPaint, Paint}; + use vectorcraft_doc::Appearance; use vectorcraft_geom::{PathData, Rect, shapes}; #[test] @@ -170,6 +161,17 @@ mod tests { let _ = PathData::default(); } + #[test] + fn warp_pins_and_maps_gradients_once() { + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut n = + Node::path(vectorcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(grad, Paint::None, 0.0)); + warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0)); + let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!() }; + let geom = g.geom.expect("pinned before the warp"); + assert!(geom.start.distance(Point::new(0.0, 6.0)) < 1e-9 && geom.end.distance(Point::new(20.0, 6.0)) < 1e-9, "{geom:?}"); + } + #[test] fn affine_near_recovers_affine_maps() { let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 8c4bca48..9307953e 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -14,17 +14,14 @@ use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; pub struct EyedropperTool; -/// The colour of `paint` at `p` (gradients are sampled along their vector). +/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring +/// their aspect ratio). pub fn paint_color_at(paint: &Paint, bounds: Option, p: Point) -> Option { match paint { Paint::Solid { color, .. } => Some(*color), Paint::Gradient(g) => { - let geom = g.resolve(bounds?); - let v = geom.end - geom.start; - let t = match g.gradient.kind { - vectorcraft_color::GradientKind::Radial => (p - geom.start).hypot() / v.hypot().max(1e-9), - _ => (p - geom.start).dot(v) / v.hypot2().max(1e-9), - }; + let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?; + let t = geom.param_at(g.gradient.kind, p); Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) } _ => None, @@ -98,4 +95,17 @@ mod tests { assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff"); assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000"); } + + #[test] + fn radial_sample_honours_aspect() { + let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { + kind: vectorcraft_color::GradientKind::Radial, + ..Default::default() + }); + // Radius 100 along x, 50 across: 50 pt above the centre is already the outer (black) edge. + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5 }); + let paint = Paint::Gradient(Box::new(g)); + assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000"); + assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080"); + } } From aa1b59293508a944758567a9176a796696d86a4b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:16:12 +0700 Subject: [PATCH 3/7] =?UTF-8?q?M3.16:=20the=20Gradient=20tool=20follows=20?= =?UTF-8?q?the=20active=20proxy=20and=20type=20objects=20=E2=80=94=20the?= =?UTF-8?q?=20tool=20and=20annotator=20edit=20the=20fill=20or=20the=20stro?= =?UTF-8?q?ke=20by=20the=20focused=20proxy=20(ToolContext.fill=5Factive);?= =?UTF-8?q?=20paint.setGradientGeom=20takes=20stroke=3F=20(default:=20the?= =?UTF-8?q?=20active=20proxy)=20and=20index=3F=20(one=20appearance=20item)?= =?UTF-8?q?,=20sets=20type=20objects'=20vectors=20on=20their=20runs=20in?= =?UTF-8?q?=20text=20space=20and=20keeps=20the=20aspect=20the=20document?= =?UTF-8?q?=20shows;=20paint.setFill/setStroke=20vectors=20on=20type=20mov?= =?UTF-8?q?e=20into=20text=20space;=20aspect=20edits=20place=20run=20gradi?= =?UTF-8?q?ents=20on=20the=20layout=20bounds;=20run=20strokes=20(and=20typ?= =?UTF-8?q?e=20appearance=20strokes)=20fit=20the=20stroke-inflated=20box?= =?UTF-8?q?=20on=20screen=20and=20in=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 11 ++- crates/doc/src/node.rs | 29 ++++++ crates/doc/src/text.rs | 5 + crates/engine/src/cmd/gradient.rs | 134 ++++++++++++++++++++++----- crates/engine/src/cmd/paint.rs | 16 ++-- crates/engine/src/tests_gradient.rs | 82 ++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/pdf/src/export.rs | 5 +- crates/render/src/lib.rs | 8 +- crates/render/tests/gradient_text.rs | 67 ++++++++++++++ crates/tools/src/lib.rs | 3 + crates/tools/src/xform/gradient.rs | 39 ++++++-- 12 files changed, 352 insertions(+), 48 deletions(-) create mode 100644 crates/render/tests/gradient_text.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index ca773897..6e35230c 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -213,13 +213,18 @@ impl StrokeLayer { effects: vec![], } } - /// The box an unplaced gradient on this stroke fits to: the object's geometric bounds grown by - /// half the weight (as the renderers resolve it). + /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { - geometric.inflate(self.width / 2.0, self.width / 2.0) + stroke_paint_bounds(geometric, self.width) } } +/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half +/// the weight (object strokes and type strokes alike, on screen and in exports). +pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect { + geometric.inflate(width / 2.0, width / 2.0) +} + /// One entry of the appearance stack. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8c3b98e9..ac658fd2 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -529,6 +529,35 @@ impl Node { self.appearance.pin_gradients(b); } } + /// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and + /// the box (in that space) an unplaced gradient fits. Type objects paint their runs (the first + /// run speaks for all) in text space, fitted to the layout bounds; everything else paints its + /// top fill or stroke in the document, fitted to the geometric bounds. Strokes fit the box + /// grown by half their weight. + pub fn proxy_paint(&self, stroke: bool) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> { + if let NodeKind::Text(t) = &self.kind { + let st = &t.runs.first()?.style; + let b = t.local_bounds(); + return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) }); + } + let b = self.geometric_bounds()?; + if stroke { + self.appearance.stroke().map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b))) + } else { + self.appearance.fill().map(|f| (&f.paint, Affine::IDENTITY, b)) + } + } + /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates + /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. + pub fn proxy_gradient(&self, stroke: bool) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { + let (paint, to_doc, b) = self.proxy_paint(stroke)?; + let vectorcraft_color::Paint::Gradient(g) = paint else { return None }; + let mut geom = g.resolve(b); + if to_doc != Affine::IDENTITY { + geom.transform(to_doc, g.gradient.kind); + } + Some((g, geom)) + } } /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 7abe79fd..89d9138a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -341,6 +341,11 @@ impl TextObject { pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } + /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run + /// gradient fits, as the renderers lay it out. + pub fn local_bounds(&self) -> Rect { + self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index f41f077d..18168c63 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -2,7 +2,9 @@ use serde_json::{Value, json}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; -use vectorcraft_doc::NodeKind; +use vectorcraft_doc::appearance::stroke_paint_bounds; +use vectorcraft_doc::{AppearanceItem, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; use super::edit::selected_roots; use super::*; @@ -24,7 +26,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)", + "{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the active proxy), index?: appearance item index (as appearance.setItem; targets that fill or stroke instead of the top one)} set the gradient vector (solid paints become the default gradient; type objects set it on their runs, in text space; the aspect ratio is kept)", has_doc, set_gradient_geom ), @@ -100,7 +102,7 @@ fn aspect_param(p: &Value) -> Parsed> { /// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is /// placed on the bounds so the aspect sticks. -pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { +pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { match (paint, p.get("gradient").and_then(|g| g.get("aspect"))) { (Paint::Gradient(g), Some(a)) if g.geom.is_none() => { apply_gradient_edit(paint, &json!({ "aspect": a }), bounds).unwrap_or_else(|_| paint.clone()) @@ -110,7 +112,7 @@ pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { +pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { let mut gp = match paint { Paint::Gradient(g) => (**g).clone(), _ => GradientPaint::new(Gradient::default()), @@ -184,9 +186,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; if let NodeKind::Text(t) = &mut n.kind { + let lb = t.local_bounds(); for r in &mut t.runs { - let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; - match apply_gradient_edit(cur, p, None) { + let (cur, b) = run_paint_mut(r, stroke, lb); + match apply_gradient_edit(cur, p, Some(b)) { Ok(np) => *cur = np, Err(e) => err = Some(e), } @@ -220,10 +223,66 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } +/// A type run's fill or stroke paint and the box (text space) an unplaced gradient on it fits: +/// the layout bounds `lb`, grown by half the run's stroke weight for strokes. +pub(crate) fn run_paint_mut(r: &mut vectorcraft_doc::TextRun, stroke: bool, lb: Rect) -> (&mut Paint, Rect) { + if stroke { + let b = stroke_paint_bounds(lb, r.style.stroke_width); + (&mut r.style.stroke, b) + } else { + (&mut r.style.fill, lb) + } +} + +/// `paint` as applied to a type run whose text space `xf` maps to the document: a vector given in +/// document coordinates moves into text space, and an aspect without a vector places the gradient +/// on the run's `bounds` (text space). +pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect) -> Paint { + let mut out = place_paint(paint, p, Some(bounds)); + if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out) + && src.geom.is_some() + && let Some(inv) = invert(xf) + { + g.transform(inv); + g.angle = g.geom.map_or(g.angle, |geom| geom.angle_deg()); + } + out +} + +/// The inverse of `a`, if it has one. +fn invert(a: Affine) -> Option { + (a.determinant().abs() > 1e-12).then(|| a.inverse()) +} + +/// `cur` as a gradient whose vector runs from `start` to `end` in the document. `to_doc` maps the +/// paint's space (text space for type runs) to the document and `bounds` (in that space) fits an +/// unplaced gradient, so the aspect ratio the document shows carries over. +fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: Option) -> Option { + let from_doc = invert(to_doc)?; + let mut gp = match cur { + Paint::Gradient(g) => (**g).clone(), + _ => GradientPaint::new(Gradient::default()), + }; + let kind = gp.gradient.kind; + let map = |mut g: GradientGeom, a: Affine| { + if a != Affine::IDENTITY { + g.transform(a, kind); + } + g + }; + let aspect = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map_or(1.0, |g| map(g, to_doc).aspect); + let geom = map(GradientGeom { start, end, aspect }, from_doc); + gp.angle = geom.angle_deg(); + gp.geom = Some(geom); + Some(Paint::Gradient(Box::new(gp))) +} + fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { - let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?; - let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?; - let stroke = bool_or(p, "stroke", false); + const C: &str = "paint.setGradientGeom"; + let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?; + let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?; + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let index = p.get("index").map(|v| v.as_u64().map(|i| i as usize).ok_or_else(|| bad(C, "`index` must be a whole number"))).transpose()?; let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, @@ -233,27 +292,54 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("nothing selected".into())); } s.edit("Gradient", |d, _| { + let mut hit = false; for id in &targets { let Some(n) = d.node_mut(*id) else { continue }; - if matches!(n.kind, NodeKind::Text(_)) { + let b = n.geometric_bounds(); + if let Some(i) = index { + let Some(item) = n.appearance.items.get_mut(i) else { continue }; + let (paint, b) = match item { + AppearanceItem::Fill(f) => (&mut f.paint, b), + AppearanceItem::Stroke(st) => { + let b = b.map(|b| st.paint_bounds(b)); + (&mut st.paint, b) + } + }; + if let Some(np) = vector_paint(paint, start, end, Affine::IDENTITY, b) { + *paint = np; + hit = true; + } continue; } - let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; - let mut gp = match cur { - Paint::Gradient(g) => *g, - _ => GradientPaint::new(Gradient::default()), - }; - let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); - gp.geom = Some(GradientGeom { start, end, aspect }); - gp.angle = GradientGeom { start, end, aspect }.angle_deg(); - let paint = Paint::Gradient(Box::new(gp)); - if stroke { - n.appearance.set_stroke(paint); - } else { - n.appearance.set_fill(paint); + if let NodeKind::Text(t) = &mut n.kind { + let (xf, lb) = (t.xf, t.local_bounds()); + for r in &mut t.runs { + if stroke && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } + let (paint, b) = run_paint_mut(r, stroke, lb); + if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) { + *paint = np; + hit = true; + } + } + continue; + } + let ap = &mut n.appearance; + let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) }; + if let Some(np) = vector_paint(&cur, start, end, Affine::IDENTITY, b) { + if stroke { + ap.set_stroke(np); + } else { + ap.set_fill(np); + } + hit = true; } } - Ok(()) + match (hit, index) { + (false, Some(i)) => Err(bad(C, format!("no appearance item at index {i}"))), + _ => Ok(()), + } })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 5af3ab35..e958c1de 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; -use super::gradient::place_paint; +use super::gradient::{place_paint, place_run_paint, run_paint_mut}; use super::*; use crate::EngineError; @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector; both or neither), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (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), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default", has_doc, |s, p| set_paint(s, p, true) ), @@ -89,15 +89,13 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; if let NodeKind::Text(t) = &mut n.kind { + let (xf, lb) = (t.xf, t.local_bounds()); for r in &mut t.runs { - if fill { - r.style.fill = paint.clone(); - } else { - r.style.stroke = paint.clone(); - if r.style.stroke_width == 0.0 { - r.style.stroke_width = 1.0; - } + if !fill && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; } + let (cur, b) = run_paint_mut(r, !fill, lb); + *cur = place_run_paint(&paint, p, xf, b); } continue; } diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 750b93af..2c873c3f 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -174,3 +174,85 @@ fn free_distort_maps_gradients_once() { want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear); assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}"); } + +// ---------- M3.16: the active proxy and type objects ---------- + +fn text(s: &mut Session, x: f64, y: f64) -> NodeId { + let r = s.execute("text.create", &json!({"x": x, "y": y, "text": "Gradient", "size": 40})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn text_obj(s: &Session, id: NodeId) -> &vectorcraft_doc::TextObject { + match &node(s, id).kind { + vectorcraft_doc::NodeKind::Text(t) => t, + k => panic!("not text: {k:?}"), + } +} + +fn run_gradient(p: &Paint) -> &GradientPaint { + match p { + Paint::Gradient(g) => g, + p => panic!("expected a gradient, got {p:?}"), + } +} + +#[test] +fn gradient_vector_follows_the_active_proxy_or_an_item_index() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.toggleActive", &json!({})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"start": [0, 50], "end": [100, 50]})).unwrap(); + let ap = &node(&s, id).appearance; + assert!(matches!(ap.fill_paint(), Paint::Solid { .. }), "the fill stays solid"); + let Paint::Gradient(g) = ap.stroke_paint() else { panic!("the stroke proxy is in front") }; + assert_eq!(g.geom.map(|g| (g.start, g.end)), Some((Point::new(0.0, 50.0), Point::new(100.0, 50.0)))); + // `index` targets one appearance item (item 0 is the fill), whichever proxy is in front. + s.execute("paint.setGradientGeom", &json!({"start": [50, 0], "end": [50, 100], "index": 0})).unwrap(); + assert_eq!(fill_geom(&s, id).start, Point::new(50.0, 0.0)); + let err = s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [1, 1], "index": 7})).unwrap_err(); + assert!(err.to_string().contains("index 7"), "{err}"); +} + +#[test] +fn type_objects_take_the_vector_on_their_runs_in_text_space() { + let mut s = session(); + let id = text(&mut s, 100.0, 200.0); + s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [300, 190]})).unwrap(); + let t = text_obj(&s, id); + let geom = run_gradient(&t.runs[0].style.fill).geom.unwrap(); + // Text space starts at the first baseline (100, 200). + assert!(geom.start.distance(Point::new(0.0, -10.0)) < 1e-9 && geom.end.distance(Point::new(200.0, -10.0)) < 1e-9, "{geom:?}"); + // The annotator reads it back in document coordinates, and it turns with the text. + let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + assert!(doc.start.distance(Point::new(100.0, 190.0)) < 1e-9, "{doc:?}"); + s.execute("object.rotate", &json!({"angle": 90, "origin": [100, 200]})).unwrap(); + let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + assert!(doc.start.distance(Point::new(90.0, 200.0)) < 1e-9 && doc.end.distance(Point::new(90.0, 0.0)) < 1e-9, "{doc:?}"); + // paint.setFill's vector is in document coordinates too. + s.execute("paint.setFill", &json!({"gradient": {"start": [90, 200], "end": [90, 100]}})).unwrap(); + let (_, doc) = node(&s, id).proxy_gradient(false).unwrap(); + assert!(doc.end.distance(Point::new(90.0, 100.0)) < 1e-9, "{doc:?}"); +} + +#[test] +fn aspect_works_on_type_and_run_strokes_fit_the_inflated_box() { + let mut s = session(); + let id = text(&mut s, 100.0, 200.0); + s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": 50, "stroke": false})).unwrap(); + let t = text_obj(&s, id); + let lb = t.local_bounds(); + let geom = run_gradient(&t.runs[0].style.fill).geom.expect("an aspect places the gradient"); + assert!((geom.aspect - 0.5).abs() < 1e-9 && geom.start.distance(lb.center()) < 1e-9, "{geom:?} on {lb:?}"); + // A stroke gradient fits the layout box grown by half the run's stroke weight. + s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": 200, "stroke": true})).unwrap(); + let t = text_obj(&s, id); + let w = t.runs[0].style.stroke_width; + let geom = run_gradient(&t.runs[0].style.stroke).geom.unwrap(); + assert!((geom.length() - (lb.width().max(lb.height()) + w) / 2.0).abs() < 1e-9, "{geom:?}"); + // The Stroke proxy's vector lands on the runs' strokes. + s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [150, 190], "stroke": true})).unwrap(); + let (g, doc) = node(&s, id).proxy_gradient(true).unwrap(); + assert!(doc.end.distance(Point::new(150.0, 190.0)) < 1e-9 && (doc.aspect - 2.0).abs() < 1e-9, "aspect kept: {doc:?}"); + assert_eq!(g.gradient.kind, GradientKind::Radial); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4203b139..bc21d1c4 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -78,6 +78,7 @@ impl Session { snap_to_pixel: view.snap_to_pixel, snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, + fill_active: self.fill_active, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..6c66d218 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -13,6 +13,7 @@ use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, St use krilla::surface::Surface; use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; +use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; @@ -607,7 +608,7 @@ impl Exporter<'_> { s.draw_path(&path); } if run.style.stroke_width > 0.0 - && let Some(paint) = self.paint(&run.style.stroke, layout.bounds) + && let Some(paint) = self.paint(&run.style.stroke, stroke_paint_bounds(layout.bounds, run.style.stroke_width)) { s.set_fill(None); s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() })); @@ -631,7 +632,7 @@ impl Exporter<'_> { } } AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { - if let Some(paint) = self.paint(&st.paint, tb) { + if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) { s.set_fill(None); s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); s.draw_path(&path); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc6..54f708c6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ mod width; use std::collections::HashMap; use std::sync::Arc; +use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -790,7 +791,10 @@ impl Renderer { self.fold_alpha(ctx, &run.style.fill); ctx.fill_path(path); } - if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, g.bounds, f.doc) { + if !run.style.stroke.is_none() + && run.style.stroke_width > 0.0 + && paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc) + { ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); ctx.stroke_path(path); } @@ -805,7 +809,7 @@ impl Renderer { ctx.set_fill_rule(peniko::Fill::NonZero); ctx.fill_path(&all); } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => { + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { ctx.set_stroke(kurbo::Stroke::new(st.width)); ctx.stroke_path(&all); } diff --git a/crates/render/tests/gradient_text.rs b/crates/render/tests/gradient_text.rs new file mode 100644 index 00000000..2857058c --- /dev/null +++ b/crates/render/tests/gradient_text.rs @@ -0,0 +1,67 @@ +//! Gradients on type: a vector across the text, and run strokes fitted like object strokes. + +use serde_json::json; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject, appearance::stroke_paint_bounds}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::{exec, session_with}; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// Mean red and blue of the solid ink (no green: the ramp has none, the paper and anti-aliased +/// edges do) in columns `x0..x1`. +fn ink_tint(img: &Image, x0: u32, x1: u32) -> (f64, f64) { + let px: Vec<[u8; 4]> = + (x0..x1).flat_map(|x| (0..img.height).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[3] == 255 && p[1] < 40).collect(); + assert!(px.len() > 20, "ink in {x0}..{x1}: {}", px.len()); + let mean = |c: usize| px.iter().map(|p| p[c] as f64).sum::() / px.len() as f64; + (mean(0), mean(2)) +} + +#[test] +fn gradient_vector_runs_across_the_text() { + let mut s = session_with(300.0, 100.0); + exec(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "HHHHHH", "size": 60})); + exec(&mut s, "paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})); + exec(&mut s, "paint.setGradientGeom", json!({"start": [10, 50], "end": [200, 50]})); + let img = render_view(&s.doc().unwrap().doc, 300, 100); + let (r0, b0) = ink_tint(&img, 10, 50); + let (r1, b1) = ink_tint(&img, 150, 200); + assert!(r0 > 180.0 && b0 < 75.0, "red where the vector starts: {r0} {b0}"); + assert!(b1 > 160.0 && r1 < 95.0, "blue where it ends: {r1} {b1}"); +} + +fn text_mut(doc: &mut Document, id: NodeId) -> &mut TextObject { + match &mut doc.node_mut(id).unwrap().kind { + NodeKind::Text(t) => t, + k => panic!("not text: {k:?}"), + } +} + +/// `doc` with every run's stroke gradient pinned to its fit on `b`. +fn pinned(doc: &Document, id: NodeId, b: Rect) -> Document { + let mut d = doc.clone(); + for r in &mut text_mut(&mut d, id).runs { + if let Paint::Gradient(g) = &mut r.style.stroke { + g.pin(b); + } + } + d +} + +#[test] +fn unplaced_run_stroke_gradients_fit_the_stroke_inflated_box() { + let mut s = session_with(300.0, 100.0); + exec(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "HHHH", "size": 60})); + exec(&mut s, "paint.setFill", json!({"none": true})); + exec(&mut s, "paint.setStroke", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})); + let id = s.doc().unwrap().selection.objects[0]; + let mut doc: Document = (*s.doc().unwrap().doc).clone(); + let t = text_mut(&mut doc, id); + t.runs.iter_mut().for_each(|r| r.style.stroke_width = 8.0); + let lb = t.local_bounds(); + let unplaced = render_view(&doc, 300, 100); + // Pinned to the fit on the layout box grown by half the 8 pt weight, it draws the same pixels; + // the bare layout box would not. + assert_eq!(render_view(&pinned(&doc, id, stroke_paint_bounds(lb, 8.0)), 300, 100), unplaced); + assert_ne!(render_view(&pinned(&doc, id, lb), 300, 100), unplaced); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f1a481f7..9d0f7d51 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -156,6 +156,8 @@ pub struct ToolContext<'a> { pub snap_to_point: bool, /// View → Show Corner Widget: live rectangles show draggable Live Corners widgets. pub corner_widgets: bool, + /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. + pub fill_active: bool, } impl ToolContext<'_> { @@ -325,6 +327,7 @@ pub(crate) mod testutil { snap_to_pixel: false, snap_to_point: true, corner_widgets: true, + fill_active: true, } } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 01df26a6..8af46ac2 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -1,12 +1,11 @@ //! Gradient tool (G). //! -//! Drag across selected objects to set the gradient vector of their fill (a solid fill becomes the -//! default gradient). Shift constrains the angle to 45°. With nothing selected, the drag targets +//! Drag across selected objects to set the gradient vector of the paint behind the active proxy +//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift constrains the angle to 45°. With nothing selected, the drag targets //! the object under the pointer. The overlay is the gradient annotator: a bar from a round start //! handle to a square end handle, with a tick for each colour stop. use serde_json::{Value, json}; -use vectorcraft_color::Paint; use vectorcraft_doc::hit::hit_test; use vectorcraft_geom::{Point, Vec2}; @@ -21,12 +20,11 @@ pub struct GradientTool { drag: Option<(Point, Point, bool)>, } -/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient fill. +/// Start/end/stop offsets of the gradient annotator: the gradient behind the active proxy (fill or +/// stroke; a type object's runs) of the first selected object that has one, in document coordinates. pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec)> { cx.selection.objects.iter().find_map(|id| { - let n = cx.doc.node(*id)?; - let Paint::Gradient(g) = n.appearance.fill_paint() else { return None }; - let geom = g.resolve(n.geometric_bounds()?); + let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?; Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect())) }) } @@ -83,7 +81,10 @@ impl Tool for GradientTool { } let end = if ev.mods.shift { start + vectorcraft_geom::constrain_angle(p - start, 45.0) } else { p }; self.drag = Some((start, end, true)); - out.push(Action::Preview("paint.setGradientGeom".into(), json!({ "start": [start.x, start.y], "end": [end.x, end.y] }))); + out.push(Action::Preview( + "paint.setGradientGeom".into(), + json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active }), + )); out } PointerKind::Up => match self.drag.take() { @@ -145,6 +146,28 @@ mod tests { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]); } + #[test] + fn stroke_proxy_drags_and_annotates_the_stroke() { + use vectorcraft_color::{Gradient, GradientPaint, Paint}; + let (mut d, id) = doc_with_rect(); + d.node_mut(id).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + // The fill is solid: with the Fill proxy in front there is no annotator. + assert!(GradientTool::default().overlays(&cx).is_empty()); + cx.fill_active = false; + // The stroke's annotator spans the stroke-inflated box (1 pt stroke: 99.5..200.5). + let (a, b, stops) = annotator(&cx).unwrap(); + assert_eq!((a, b, stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2)); + let mut t = GradientTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 150.0)); + let Action::Preview(_, v) = &a[1] else { panic!("{a:?}") }; + assert_eq!(v["stroke"], json!(true)); + } + #[test] fn empty_selection_targets_hit_object() { let (d, id) = doc_with_rect(); From 3786cfaf363662d48bda4013d6eb0ca47ee8becb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:39:36 +0700 Subject: [PATCH 4/7] =?UTF-8?q?M3.17:=20interactive=20gradient=20annotator?= =?UTF-8?q?=20=E2=80=94=20drag=20the=20start=20handle=20or=20the=20bar=20t?= =?UTF-8?q?o=20move=20the=20gradient,=20the=20end=20handle=20to=20change?= =?UTF-8?q?=20length=20and=20angle,=20just=20past=20the=20end=20to=20rotat?= =?UTF-8?q?e=20(Shift=20snaps=20to=2045=C2=B0);=20a=20bar=20click=20adds?= =?UTF-8?q?=20a=20stop,=20stops=20drag=20along=20the=20bar,=20off=20it=20t?= =?UTF-8?q?o=20delete=20(never=20below=20two),=20Alt-drag=20copies,=20diam?= =?UTF-8?q?onds=20move=20midpoints;=20a=20click=20inside=20selected=20art?= =?UTF-8?q?=20applies=20the=20gradient=20from=20that=20point;=20double-cli?= =?UTF-8?q?cking=20a=20stop=20opens=20a=20popover=20(Color/Swatches,=20Opa?= =?UTF-8?q?city,=20Location);=20the=20selected=20stop=20lives=20in=20the?= =?UTF-8?q?=20session=20(gradient.selectStop),=20shared=20by=20the=20tool,?= =?UTF-8?q?=20the=20Gradient=20and=20Color=20panels=20and=20agents,=20and?= =?UTF-8?q?=20takes=20Delete/Backspace=20and=20=E2=86=90/=E2=86=92=20ahead?= =?UTF-8?q?=20of=20their=20shortcuts=20(Tool::claims=5Fkey,=20also=20headl?= =?UTF-8?q?ess);=20stop=20chips=20draw=20as=20Overlay::Swatch=20with=20gen?= =?UTF-8?q?erated=20add/remove-stop=20cursors;=20the=20stop=20math=20moves?= =?UTF-8?q?=20into=20the=20color=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 135 ++++ crates/engine/src/cmd/gradient.rs | 42 ++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests_gradient.rs | 72 ++- crates/engine/src/tooling.rs | 6 + crates/mcp/src/headless.rs | 5 +- crates/mcp/src/lib.rs | 2 + crates/mcp/src/tests_gradient.rs | 55 ++ crates/tools/src/lib.rs | 14 + crates/tools/src/xform/gradient.rs | 676 +++++++++++++++++--- crates/ui-egui/src/canvas.rs | 13 + crates/ui-egui/src/cursors.rs | 13 + crates/ui-egui/src/dialogs/gradient_stop.rs | 251 ++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/tools.rs | 6 +- crates/ui-egui/src/panels/color.rs | 8 +- crates/ui-egui/src/panels/gradient.rs | 151 +---- crates/ui-egui/src/shortcuts.rs | 11 + docs/control-protocol.md | 1 + docs/mcp.md | 12 + 20 files changed, 1263 insertions(+), 216 deletions(-) create mode 100644 crates/mcp/src/tests_gradient.rs create mode 100644 crates/ui-egui/src/dialogs/gradient_stop.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 46ef0440..db259875 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -116,6 +116,80 @@ impl Gradient { } } +// ---------- stop editing (the Gradient panel, the annotator and agents share these) ---------- + +/// Fewest stops a gradient keeps: deleting a stop never goes below this. +pub const MIN_STOPS: usize = 2; + +/// Put `s` among `stops` (sorted by offset, after stops at the same offset). Returns its index. +fn place_stop(stops: &mut Vec, s: GradientStop) -> usize { + let i = stops.iter().position(|o| o.offset > s.offset).unwrap_or(stops.len()); + stops.insert(i, s); + i +} + +/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and +/// the new stop's index. +pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec, usize) { + let offset = offset.clamp(0.0, 1.0); + let (color, opacity) = g.sample(offset); + let mut stops = g.stops.clone(); + let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 }); + (stops, i) +} + +/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`]. +pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option> { + if stops.len() <= MIN_STOPS || i >= stops.len() { + return None; + } + let mut v = stops.to_vec(); + v.remove(i); + Some(v) +} + +/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index. +pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec, usize) { + let mut v = stops.to_vec(); + if i >= v.len() { + return (v, i); + } + let mut s = v.remove(i); + s.offset = offset.clamp(0.0, 1.0); + let ni = place_stop(&mut v, s); + (v, ni) +} + +/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index. +pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec, usize) { + let mut v = stops.to_vec(); + let Some(&s) = v.get(i) else { return (v, i) }; + let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s }); + (v, ni) +} + +/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %). +pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec { + let mut v = stops.to_vec(); + if let Some(s) = v.get_mut(i) { + s.midpoint = m.clamp(0.13, 0.87); + } + v +} + +/// Absolute position (0..1) of the midpoint diamond after stop `i`. +pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option { + let (a, b) = (stops.get(i)?, stops.get(i + 1)?); + Some(a.offset + (b.offset - a.offset) * a.midpoint) +} + +/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position. +pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option { + let (a, b) = (stops.get(i)?, stops.get(i + 1)?); + let span = (b.offset - a.offset).max(1e-6); + Some(((pos - a.offset) / span).clamp(0.13, 0.87)) +} + /// Where the gradient sits on an object, in document coordinates. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientGeom { @@ -357,6 +431,67 @@ mod tests { assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12); } + fn g3() -> Gradient { + let mut g = Gradient::default(); + g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }); + g + } + + #[test] + fn insert_samples_color_and_sorts() { + let g = Gradient::default(); + let (stops, i) = insert_stop(&g, 0.25); + assert_eq!(stops.len(), 3); + assert_eq!(i, 1); + let r = stops[1].color.to_rgb()[0]; + assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); + let (stops, i) = insert_stop(&g, 2.0); + assert_eq!(i, 2); + assert_eq!(stops[2].offset, 1.0); + } + + #[test] + fn remove_keeps_two() { + let g = g3(); + let v = remove_stop(&g.stops, 1).unwrap(); + assert_eq!(v.len(), 2); + assert!(remove_stop(&v, 0).is_none()); + assert!(remove_stop(&g.stops, 9).is_none()); + } + + #[test] + fn move_reorders_and_tracks_index() { + let g = g3(); + let (v, i) = move_stop(&g.stops, 0, 0.8); + assert_eq!(i, 1); + assert_eq!(v[1].color.to_hex(), "#ffffff"); + assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); + let (v, i) = move_stop(&g.stops, 1, -3.0); + // Clamped to 0 and placed after the existing stop at 0. + assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); + } + + #[test] + fn duplicate_copies_the_stop_to_a_new_offset() { + let g = g3(); + let (v, i) = duplicate_stop(&g.stops, 1, 0.9); + assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string())); + assert_eq!(v[1], g.stops[1], "the original stays"); + assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops); + } + + #[test] + fn midpoint_math() { + let g = g3(); + assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); + assert_eq!(midpoint_pos(&g.stops, 2), None); + let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); + assert!((m - 0.2).abs() < 1e-5); + assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); + let v = set_midpoint(&g.stops, 0, 0.99); + assert_eq!(v[0].midpoint, 0.87); + } + #[test] fn pin_fixes_the_fit_once() { let mut p = GradientPaint::new(Gradient::default()); diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 18168c63..37e94859 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -30,9 +30,51 @@ pub fn specs() -> Vec { has_doc, set_gradient_geom ), + cmd!( + "gradient.selectStop", + "Select Gradient Stop", + [], + None, + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + always, + select_stop + ), ] } +/// The gradient behind the active proxy: the first selected object's (see +/// `Node::proxy_paint`), else the default paint for new art. +pub(crate) fn active_gradient(s: &Session) -> Option { + let stroke = !s.fill_active; + let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))); + let paint = match first { + Some(n) => n.proxy_paint(stroke).map(|(p, ..)| p.clone()), + None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }), + }; + match paint { + Some(Paint::Gradient(g)) => Some(*g), + _ => None, + } +} + +fn select_stop(s: &mut Session, p: &Value) -> Result { + const C: &str = "gradient.selectStop"; + let index = match p.get("index") { + None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), + Some(Value::Null) => None, + Some(v) => { + let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize; + let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len(); + if i >= n { + return Err(bad(C, format!("no stop {i} (the gradient has {n})"))); + } + Some(i) + } + }; + s.gradient_stop = index; + Ok(json!({ "index": index })) +} + type Parsed = std::result::Result; /// Parse a kind name. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b435ad68..fc962147 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -443,6 +443,9 @@ pub struct Session { untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, + /// The selected gradient stop (`gradient.selectStop`), shared by the Gradient tool's annotator, + /// the Gradient and Color panels and agents. + pub gradient_stop: Option, } impl Default for Session { @@ -468,6 +471,7 @@ impl Session { draw_inside: None, untitled_counter: 0, menu: Default::default(), + gradient_stop: None, } } diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 2c873c3f..934e8b5b 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -1,8 +1,10 @@ -//! Gradients through commands: lossless, validated params (M3.14). +//! Gradients through commands: lossless, validated params (M3.14), transforms (M3.15), the active +//! proxy and type (M3.16) and the interactive annotator (M3.17). use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::Point; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use super::*; @@ -256,3 +258,71 @@ fn aspect_works_on_type_and_run_strokes_fit_the_inflated_box() { assert!(doc.end.distance(Point::new(150.0, 190.0)) < 1e-9 && (doc.aspect - 2.0).abs() < 1e-9, "aspect kept: {doc:?}"); assert_eq!(g.gradient.kind, GradientKind::Radial); } + +// ---------- M3.17: the interactive annotator ---------- + +/// A 100 × 100 rectangle at (100, 100) with a horizontal fill gradient across it, and the +/// Gradient tool. +fn annotated() -> (Session, NodeId) { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"start": [100, 150], "end": [200, 150]}})).unwrap(); + s.select_tool("gradient", crate::ViewInfo::default()).unwrap(); + (s, id) +} + +fn gesture(s: &mut Session, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y), crate::ViewInfo::default()).unwrap(); + } +} + +fn stop_count(s: &Session, id: NodeId) -> usize { + fill_gradient(s, id).gradient.stops.len() +} + +#[test] +fn select_stop_is_validated_and_shared() { + let (mut s, _) = annotated(); + assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1})); + assert_eq!(s.gradient_stop, Some(1)); + assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops"); + assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err()); + assert!(s.execute("gradient.selectStop", &json!({})).is_err()); + s.execute("gradient.selectStop", &json!({"index": null})).unwrap(); + assert_eq!(s.gradient_stop, None); + // The stroke proxy's paint is solid: nothing to select there. + s.execute("paint.toggleActive", &json!({})).unwrap(); + assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err()); +} + +#[test] +fn annotator_gestures_edit_the_gradient_as_single_undo_steps() { + let (mut s, id) = annotated(); + // A click on the bar adds a stop there and selects it. + gesture(&mut s, &[(PointerKind::Down, 130.0, 150.0), (PointerKind::Up, 130.0, 150.0)]); + let stops = fill_gradient(&s, id).gradient.stops; + assert_eq!(stops.len(), 3); + assert!((stops[1].offset - 0.3).abs() < 1e-6); + assert_eq!(s.gradient_stop, Some(1)); + // Dragging the end handle changes only the end, as one undo step. + let undo = s.doc().unwrap().history.undo.len(); + gesture( + &mut s, + &[(PointerKind::Down, 200.0, 150.0), (PointerKind::Drag, 210.0, 160.0), (PointerKind::Drag, 220.0, 170.0), (PointerKind::Up, 220.0, 170.0)], + ); + let g = fill_geom(&s, id); + assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(220.0, 170.0))); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + // The selected stop takes Delete ahead of edit.clear; the object stays. + let view = crate::ViewInfo::default(); + assert!(s.tool_claims_key(ToolKey::Delete, view)); + s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap(); + assert_eq!(stop_count(&s, id), 2); + assert_eq!(s.gradient_stop, Some(1)); + // Never below two stops. + s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap(); + assert_eq!(stop_count(&s, id), 2); + assert!(s.doc().unwrap().doc.node(id).is_some()); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index bc21d1c4..87d13fdf 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -79,6 +79,7 @@ impl Session { snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, fill_active: self.fill_active, + gradient_stop: self.gradient_stop, }; f(self.tool.as_mut(), &cx) } @@ -109,6 +110,11 @@ impl Session { self.tool.busy() } + /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? + pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool { + self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) + } + pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); if let Some(d) = self.active() { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 48fc6114..d18ee36a 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -166,9 +166,10 @@ impl Headless { let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() }; let tk = tool_key(key); - // A busy tool (pen path in progress, shape being dragged) gets its keys first. + // A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys + // the tool claims (the Gradient tool's selected stop takes Delete and the arrows). if let Some(k) = tk - && self.session.tool_busy() + && (self.session.tool_busy() || self.session.tool_claims_key(k, self.view)) { let mut out = vec![]; let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?; diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 38c06092..5b827362 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -31,3 +31,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; mod tests; #[cfg(test)] mod tests_fileio; +#[cfg(test)] +mod tests_gradient; diff --git a/crates/mcp/src/tests_gradient.rs b/crates/mcp/src/tests_gradient.rs new file mode 100644 index 00000000..302926ed --- /dev/null +++ b/crates/mcp/src/tests_gradient.rs @@ -0,0 +1,55 @@ +//! The Gradient tool's annotator through MCP: `pointer_gesture` edits stops and the vector, +//! `press_key` reaches the selected stop. + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{GradientPaint, Paint}; +use vectorcraft_geom::Point; + +use crate::{Backend, Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +/// The fill gradient of the selected object. +fn gradient(h: &Headless) -> GradientPaint { + let st = h.session.doc().unwrap(); + match st.doc.node(st.selection.objects[0]).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("not a gradient: {p:?}"), + } +} + +fn offsets(g: &GradientPaint) -> Vec { + g.gradient.stops.iter().map(|s| (s.offset * 100.0).round()).collect() +} + +#[test] +fn gradient_annotator_end_to_end() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + ok(&mut h, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100})); + ok(&mut h, "run_command", json!({"command": "paint.setFill", "params": {"gradient": {"start": [100, 150], "end": [200, 150]}}})); + // A click on the bar adds a stop at 30 %. + let click = json!({"tool": "gradient", "events": [{"kind": "down", "x": 130, "y": 150}, {"kind": "up", "x": 130, "y": 150}]}); + ok(&mut h, "pointer_gesture", click); + assert_eq!(offsets(&gradient(&h)), vec![0.0, 30.0, 100.0]); + // Drag the new stop (its chip sits 10 px under the bar) to 60 %. + let drag = json!({"events": [{"kind": "down", "x": 130, "y": 160}, {"kind": "drag", "x": 145, "y": 160}, {"kind": "drag", "x": 160, "y": 160}, {"kind": "up", "x": 160, "y": 160}]}); + ok(&mut h, "pointer_gesture", drag); + assert_eq!(offsets(&gradient(&h)), vec![0.0, 60.0, 100.0]); + // The dragged stop stays selected: Delete removes it (and leaves the rectangle). + let r = ok(&mut h, "press_key", json!({"key": "Delete"})); + assert_eq!(r["handledBy"], "tool", "{r}"); + assert_eq!(offsets(&gradient(&h)), vec![0.0, 100.0]); + // The end handle changes only the end. + let end = json!({"events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "drag", "x": 230, "y": 120}, {"kind": "up", "x": 230, "y": 120}]}); + ok(&mut h, "pointer_gesture", end); + let g = gradient(&h).geom.unwrap(); + assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(230.0, 120.0))); + // Double-clicking a stop asks for its popover. + let dbl = ok(&mut h, "pointer_gesture", json!({"events": [{"kind": "doubleclick", "x": 100, "y": 160}]})); + assert_eq!(dbl["requests"][0]["dialog"], "gradientStop", "{dbl}"); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9d0f7d51..f0f6c0a1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -158,6 +158,8 @@ pub struct ToolContext<'a> { pub corner_widgets: bool, /// Which paint proxy is in front (true = Fill): the one the Gradient tool edits. pub fill_active: bool, + /// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator. + pub gradient_stop: Option, } impl ToolContext<'_> { @@ -189,6 +191,8 @@ pub enum Overlay { Measure { p: Point, text: String }, /// Translucent filled quad (text selection highlight), RGBA. Highlight { quad: [Point; 4], color: [u8; 4] }, + /// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected. + Swatch { p: Point, color: [u8; 4], selected: bool }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -217,6 +221,10 @@ pub enum Cursor { ZoomOut, Eyedropper, NotAllowed, + /// Over the gradient annotator's bar: a click adds a stop. + AddStop, + /// A gradient stop dragged off the bar: releasing deletes it. + RemoveStop, } /// A tool state machine. @@ -241,6 +249,11 @@ pub trait Tool: Send { fn busy(&self) -> bool { false } + /// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool + /// with a stop selected takes Delete and the arrows)? + fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { + false + } /// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed. fn wants_text(&self) -> bool { false @@ -328,6 +341,7 @@ pub(crate) mod testutil { snap_to_point: true, corner_widgets: true, fill_active: true, + gradient_stop: None, } } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 8af46ac2..8c4cc188 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -1,13 +1,24 @@ -//! Gradient tool (G). +//! Gradient tool (G) and the gradient annotator. //! //! Drag across selected objects to set the gradient vector of the paint behind the active proxy -//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift constrains the angle to 45°. With nothing selected, the drag targets -//! the object under the pointer. The overlay is the gradient annotator: a bar from a round start -//! handle to a square end handle, with a tick for each colour stop. +//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift +//! constrains the angle to 45°. With nothing selected, the press targets the object under the +//! pointer; a click inside selected art applies the gradient from that point. +//! +//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle +//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past +//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add +//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a +//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The +//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or +//! Backspace removes it (never below two stops) and the arrow keys nudge it. use serde_json::{Value, json}; +use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint}; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; +use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::hit_test; -use vectorcraft_geom::{Point, Vec2}; +use vectorcraft_geom::{Affine, Point, Vec2}; use super::paint_owner; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -15,104 +26,412 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool const BAR: [u8; 3] = [0x20, 0x20, 0x20]; const LIGHT: [u8; 3] = [0xf0, 0xf0, 0xf0]; +// Annotator layout and hit radii, in screen pixels. +/// Start and end handles. +const HANDLE: f64 = 5.0; +/// The rotate zone reaches this far past the end handle. +const ROTATE: f64 = 14.0; +/// Stop chips sit this far below the bar. +const STOP_GAP: f64 = 10.0; +const STOP_HIT: f64 = 6.0; +/// Midpoint diamonds sit this far above the bar. +const MID_GAP: f64 = 7.0; +const MID_HIT: f64 = 5.0; +/// The bar (where a click adds a stop) reaches this far above it, and down through the stop row. +const BAR_HIT: f64 = 4.0; +/// A stop dragged farther than this from the bar is deleted on release. +const OFF_BAR: f64 = 24.0; +/// A press becomes a drag after this much movement. +const DRAG_START: f64 = 3.0; +/// Arrow-key nudges of the selected stop (Shift: the big step). +const NUDGE: f32 = 0.01; +const NUDGE_BIG: f32 = 0.1; + +/// The gradient annotator: the gradient behind the active proxy of the first selected object that +/// has one, placed in document coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Annotator { + pub geom: GradientGeom, + pub gradient: Gradient, +} + +/// A part of the annotator. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Part { + Stop(usize), + /// The midpoint diamond after stop `i`. + Mid(usize), + Start, + End, + /// Just past the end: rotates the vector about the start. + Rotate, + /// The bar at an offset (0..1): a click adds a stop there. + Bar(f32), +} + +impl Annotator { + pub fn of(cx: &ToolContext) -> Option { + cx.selection.objects.iter().find_map(|id| { + let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?; + Some(Self { geom, gradient: g.gradient.clone() }) + }) + } + /// An annotator showing `stops` on `geom` (the state a drag started from). + fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self { + Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } } + } + fn vector(&self) -> Vec2 { + self.geom.end - self.geom.start + } + /// The point at offset `t` along the bar. + pub fn at(&self, t: f64) -> Point { + self.geom.start + self.vector() * t + } + /// Unit normal to the bar, towards the stop row. + fn normal(&self) -> Vec2 { + let v = self.vector(); + let len = v.hypot(); + if len < 1e-12 { Vec2::new(0.0, 1.0) } else { Vec2::new(-v.y, v.x) / len } + } + /// Where stop `i`'s chip sits. + pub fn stop_point(&self, cx: &ToolContext, i: usize) -> Option { + let s = self.gradient.stops.get(i)?; + Some(self.at(s.offset as f64) + self.normal() * cx.tol(STOP_GAP)) + } + /// Where the midpoint diamond after stop `i` sits. + pub fn mid_point(&self, cx: &ToolContext, i: usize) -> Option { + Some(self.at(midpoint_pos(&self.gradient.stops, i)? as f64) - self.normal() * cx.tol(MID_GAP)) + } + /// `p` as (offset along the bar, signed distance from it towards the stop row). + fn project(&self, p: Point) -> (f64, f64) { + let v = self.vector(); + let l2 = v.hypot2(); + let d = p - self.geom.start; + if l2 < 1e-18 { + return (0.0, d.hypot()); + } + (d.dot(v) / l2, d.dot(self.normal())) + } + /// The part under `p`: stops, then midpoints, the handles, the rotate zone and the bar. + pub fn hit(&self, cx: &ToolContext, p: Point) -> Option { + let near = |q: Option, r: f64| q.is_some_and(|q| q.distance(p) <= cx.tol(r)); + let n = self.gradient.stops.len(); + // The topmost (last drawn) chip wins where chips overlap. + if let Some(i) = (0..n).rev().find(|i| near(self.stop_point(cx, *i), STOP_HIT)) { + return Some(Part::Stop(i)); + } + if let Some(i) = (0..n.saturating_sub(1)).find(|i| near(self.mid_point(cx, *i), MID_HIT)) { + return Some(Part::Mid(i)); + } + if near(Some(self.geom.end), HANDLE) { + return Some(Part::End); + } + if near(Some(self.geom.start), HANDLE) { + return Some(Part::Start); + } + let (t, d) = self.project(p); + if t > 1.0 && near(Some(self.geom.end), ROTATE) { + return Some(Part::Rotate); + } + let across = -cx.tol(BAR_HIT)..=cx.tol(STOP_GAP + STOP_HIT); + if self.vector().hypot() > 1e-12 && (0.0..=1.0).contains(&t) && across.contains(&d) { + return Some(Part::Bar(t as f32)); + } + None + } +} + +/// What a press grabbed. +#[derive(Clone, Debug, PartialEq)] +enum Grab { + /// The art (or empty canvas): a click applies the gradient from there, a drag draws a vector. + Art, + /// The start handle or the bar: a drag moves the gradient (a click on the bar adds a stop). + Move { + geom: GradientGeom, + bar: Option, + }, + End(GradientGeom), + Rotate(GradientGeom), + Stop { + index: usize, + from: Annotator, + copy: bool, + }, + Mid { + index: usize, + from: Annotator, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct Gesture { + grab: Grab, + /// Where the press happened. + at: Point, + /// Moved past the drag threshold (an interaction is open). + began: bool, + /// The vector drawn so far (Art drags). + vector: Option<(Point, Point)>, + /// Where a dragged stop ends up (selected on release): its index, or None while it is dragged + /// off the bar. + stop: Option>, +} + #[derive(Default)] pub struct GradientTool { - drag: Option<(Point, Point, bool)>, + gesture: Option, } -/// Start/end/stop offsets of the gradient annotator: the gradient behind the active proxy (fill or -/// stroke; a type object's runs) of the first selected object that has one, in document coordinates. -pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec)> { - cx.selection.objects.iter().find_map(|id| { - let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?; - Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect())) - }) -} - -fn annotator_overlays(cx: &ToolContext, a: Point, b: Point, stops: &[f32]) -> Vec { +/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop. +fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { + let (s, e) = (a.geom.start, a.geom.end); + let shade = Vec2::new(0.0, cx.tol(1.0)); let mut o = vec![ - Overlay::Line { a, b, color: BAR, dashed: false }, - Overlay::Line { a: a + Vec2::new(0.0, cx.tol(1.0)), b: b + Vec2::new(0.0, cx.tol(1.0)), color: LIGHT, dashed: false }, - Overlay::Handle { p: a, color: BAR }, - Overlay::Anchor { p: b, color: BAR, filled: true, size: 7.0 }, + Overlay::Line { a: s, b: e, color: BAR, dashed: false }, + Overlay::Line { a: s + shade, b: e + shade, color: LIGHT, dashed: false }, + Overlay::Handle { p: s, color: BAR }, + Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 }, ]; - let v = b - a; - let len = v.hypot(); - if len > 1e-9 { - let n = Vec2::new(-v.y, v.x) / len * cx.tol(6.0); - for s in stops { - let p = a + v * *s as f64; - o.push(Overlay::Line { a: p, b: p + n, color: BAR, dashed: false }); - } + let len = a.vector().hypot(); + if len < 1e-12 { + return o; + } + let r = cx.tol(4.0); + let (along, across) = (a.vector() / len * r, a.normal() * r); + for i in 0..a.gradient.stops.len().saturating_sub(1) { + let Some(c) = a.mid_point(cx, i) else { continue }; + let path = super::polygon(&[c - along, c - across, c + along, c + across], true); + o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: false }); + } + for (i, st) in a.gradient.stops.iter().enumerate() { + let Some(p) = a.stop_point(cx, i) else { continue }; + o.push(Overlay::Line { a: a.at(st.offset as f64), b: p, color: BAR, dashed: false }); + o.push(Overlay::Swatch { p, color: st.color.to_rgba8(st.opacity), selected: cx.gradient_stop == Some(i) }); } o } +/// The vector a click applies to `id`: its current gradient's, else the default fit on its box. +fn click_vector(cx: &ToolContext, id: NodeId) -> Option { + let stroke = !cx.fill_active; + let n = cx.doc.node(id)?; + if let Some((_, g)) = n.proxy_gradient(stroke) { + return Some(g.end - g.start); + } + let (to_doc, b) = match n.proxy_paint(stroke) { + Some((_, a, b)) => (a, b), + None => (Affine::IDENTITY, n.geometric_bounds()?), + }; + let mut g = GradientGeom::fit(GradientKind::Linear, b, 0.0); + g.transform(to_doc, GradientKind::Linear); + Some(g.end - g.start) +} + +impl GradientTool { + fn geom_params(cx: &ToolContext, start: Point, end: Point) -> Value { + json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active }) + } + fn stops_params(cx: &ToolContext, stops: &[GradientStop]) -> Value { + json!({ "stops": crate::params::stops_json(stops), "stroke": !cx.fill_active }) + } + fn select_stop(i: usize) -> Action { + Action::Exec("gradient.selectStop".into(), json!({ "index": i })) + } + + fn press(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec { + let annotator = Annotator::of(cx); + let mut out = vec![]; + let grab = match annotator.as_ref().and_then(|a| a.hit(cx, p).map(|part| (a, part))) { + Some((a, Part::Stop(index))) => { + out.push(Self::select_stop(index)); + Grab::Stop { index, from: a.clone(), copy: mods.alt } + } + Some((a, Part::Mid(index))) => Grab::Mid { index, from: a.clone() }, + Some((a, Part::Start)) => Grab::Move { geom: a.geom, bar: None }, + Some((a, Part::Bar(t))) => Grab::Move { geom: a.geom, bar: Some(t) }, + Some((a, Part::End)) => Grab::End(a.geom), + Some((a, Part::Rotate)) => Grab::Rotate(a.geom), + None => { + if cx.selection.is_empty() { + let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out }; + out.push(Action::Exec("select.set".into(), json!({ "ids": [paint_owner(cx.doc, h.leaf).0] }))); + } + Grab::Art + } + }; + self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None }); + out + } + + fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec { + let Some(g) = &mut self.gesture else { return vec![] }; + let mut out = vec![]; + if !g.began { + if p.distance(g.at) < cx.tol(DRAG_START) { + return out; + } + g.began = true; + out.push(Action::Begin("Gradient".into())); + } + let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to }; + let (cmd, params) = match &g.grab { + Grab::Art => { + let end = snap(g.at, p); + g.vector = Some((g.at, end)); + ("paint.setGradientGeom", Self::geom_params(cx, g.at, end)) + } + Grab::Move { geom, .. } => { + let d = p - g.at; + ("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d)) + } + Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))), + Grab::Rotate(geom) => { + let dir = snap(geom.start, p) - geom.start; + let len = dir.hypot(); + let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) }; + ("paint.setGradientGeom", Self::geom_params(cx, geom.start, end)) + } + Grab::Stop { index, from, copy } => { + let (t, d) = from.project(p); + let t = t.clamp(0.0, 1.0) as f32; + let stops = &from.gradient.stops; + let removed = (!copy && d.abs() > cx.tol(OFF_BAR)).then(|| remove_stop(stops, *index)).flatten(); + let (stops, at) = match removed { + Some(v) => (v, None), + None => { + let (v, i) = if *copy { duplicate_stop(stops, *index, t) } else { move_stop(stops, *index, t) }; + (v, Some(i)) + } + }; + g.stop = Some(at); + ("paint.editGradient", Self::stops_params(cx, &stops)) + } + Grab::Mid { index, from } => { + let stops = &from.gradient.stops; + let m = midpoint_from_pos(stops, *index, from.project(p).0.clamp(0.0, 1.0) as f32).unwrap_or(0.5); + ("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m))) + } + }; + out.push(Action::Preview(cmd.into(), params)); + out + } + + fn release(&mut self, cx: &ToolContext, p: Point) -> Vec { + let Some(g) = self.gesture.take() else { return vec![] }; + if g.began { + let mut out = vec![Action::Commit]; + match (g.grab, g.stop) { + (Grab::Stop { .. }, Some(Some(i))) => out.push(Self::select_stop(i)), + // Dragged off the bar: the stop that slid into its place is selected. + (Grab::Stop { index, from, .. }, Some(None)) => out.push(Self::select_stop(index.min(from.gradient.stops.len() - 2))), + _ => {} + } + return out; + } + match g.grab { + // A click on the bar adds a stop there. + Grab::Move { bar: Some(t), .. } => { + let Some(a) = Annotator::of(cx) else { return vec![] }; + let (stops, i) = insert_stop(&a.gradient, t); + vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &stops)), Self::select_stop(i)] + } + // A click inside selected art applies the gradient from that point. + Grab::Art => { + let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return vec![] }; + let id = paint_owner(cx.doc, h.leaf); + let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; + let mut params = Self::geom_params(cx, p, p + v); + params["ids"] = json!([id.0]); + vec![Action::Exec("paint.setGradientGeom".into(), params)] + } + _ => vec![], + } + } +} + impl Tool for GradientTool { fn id(&self) -> &'static str { "gradient" } fn busy(&self) -> bool { - self.drag.is_some_and(|d| d.2) + self.gesture.as_ref().is_some_and(|g| g.began) } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; match ev.kind { - PointerKind::Down => { - let mut out = vec![]; - if cx.selection.is_empty() { - let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out }; - let id = paint_owner(cx.doc, h.leaf); - out.push(Action::Exec("select.set".into(), json!({ "ids": [id.0] }))); - } - self.drag = Some((p, p, false)); - out + PointerKind::Down => self.press(cx, ev.pos, ev.mods), + PointerKind::Drag => self.drag(cx, ev.pos, ev.mods), + PointerKind::Up => self.release(cx, ev.pos), + PointerKind::DoubleClick => { + let Some(a) = Annotator::of(cx) else { return vec![] }; + let Some(Part::Stop(i)) = a.hit(cx, ev.pos) else { return vec![] }; + let at = a.stop_point(cx, i).unwrap_or(ev.pos); + vec![Self::select_stop(i), Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))] } - PointerKind::Drag => { - let Some((start, _, began)) = self.drag else { return vec![] }; - let mut out = vec![]; - if !began { - if p.distance(start) < cx.tol(3.0) { - return out; - } - out.push(Action::Begin("Gradient".into())); - } - let end = if ev.mods.shift { start + vectorcraft_geom::constrain_angle(p - start, 45.0) } else { p }; - self.drag = Some((start, end, true)); - out.push(Action::Preview( - "paint.setGradientGeom".into(), - json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active }), - )); - out - } - PointerKind::Up => match self.drag.take() { - Some((_, _, true)) => vec![Action::Commit], - _ => vec![], - }, - _ => vec![], + PointerKind::Move => vec![], } } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right) + && cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len())) + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { if key == ToolKey::Escape && self.busy() { - self.drag = None; + self.gesture = None; return vec![Action::Cancel]; } - vec![] + if self.busy() || !self.claims_key(cx, key) { + return vec![]; + } + let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] }; + let stops = &a.gradient.stops; + let (new, sel) = match key { + // Never below two stops: Delete then does nothing. + ToolKey::Delete | ToolKey::Backspace => match remove_stop(stops, i) { + Some(v) => { + let sel = i.min(v.len() - 1); + (v, sel) + } + None => return vec![], + }, + _ => { + let step = if mods.shift { NUDGE_BIG } else { NUDGE }; + move_stop(stops, i, stops[i].offset + if key == ToolKey::Left { -step } else { step }) + } + }; + vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &new)), Self::select_stop(sel)] } fn overlays(&self, cx: &ToolContext) -> Vec { - match (self.drag, annotator(cx)) { - (_, Some((a, b, stops))) => annotator_overlays(cx, a, b, &stops), - (Some((a, b, true)), None) => annotator_overlays(cx, a, b, &[0.0, 1.0]), + match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) { + (Some(a), _) => annotator_overlays(cx, &a), + // A vector drawn on a paint that isn't a gradient yet. + (None, Some((s, e))) => { + annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops)) + } _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if let Some(g) = &self.gesture { + return match (&g.grab, g.stop) { + (Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop, + (Grab::Rotate(_), _) => Cursor::Rotate, + (Grab::Art, _) => Cursor::Crosshair, + _ => Cursor::Move, + }; + } + match Annotator::of(cx).and_then(|a| a.hit(cx, p)) { + Some(Part::Bar(_)) => Cursor::AddStop, + Some(Part::Rotate) => Cursor::Rotate, + Some(_) => Cursor::Move, + None => Cursor::Crosshair, + } } fn options(&self) -> Value { @@ -124,7 +443,58 @@ impl Tool for GradientTool { mod tests { use super::*; use crate::testutil::*; - use vectorcraft_doc::Selection; + use vectorcraft_color::{GradientPaint, Paint}; + use vectorcraft_doc::{Document, Selection}; + + fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) + } + + fn with(m: Mods, e: PointerEvent) -> PointerEvent { + e.with_mods(m) + } + + const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; + const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false }; + + /// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150). + fn graded(stops: Vec) -> (Document, Selection) { + let (mut d, id) = doc_with_rect(); + let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0 }); + d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g))); + let mut s = Selection::default(); + s.add(id); + (d, s) + } + + fn two() -> Vec { + Gradient::default().stops + } + + fn three() -> Vec { + insert_stop(&Gradient::default(), 0.5).0 + } + + fn preview(a: &[Action]) -> &Value { + a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v) } else { None }).unwrap_or_else(|| panic!("no preview in {a:?}")) + } + + fn exec(a: &Action) -> (&str, &Value) { + match a { + Action::Exec(c, v) => (c.as_str(), v), + a => panic!("not an Exec: {a:?}"), + } + } + + fn point(v: &Value) -> Point { + Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) + } + + /// Stop offsets in percent (rounded: offsets travel as f32). + fn offsets(v: &Value) -> Vec { + v["stops"].as_array().unwrap().iter().map(|s| (s["offset"].as_f64().unwrap() * 100.0).round()).collect() + } #[test] fn drag_sets_gradient_vector_and_constrains() { @@ -135,7 +505,7 @@ mod tests { let cx = cx(&d, &s, &p); let mut t = GradientTool::default(); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 152.0).with_mods(Mods { shift: true, ..Default::default() })); + let a = t.pointer(&cx, &with(SHIFT, PointerEvent::new(PointerKind::Drag, 190.0, 152.0))); assert_eq!(a[0], Action::Begin("Gradient".into())); let Action::Preview(c, v) = &a[1] else { panic!() }; assert_eq!(c, "paint.setGradientGeom"); @@ -148,7 +518,6 @@ mod tests { #[test] fn stroke_proxy_drags_and_annotates_the_stroke() { - use vectorcraft_color::{Gradient, GradientPaint, Paint}; let (mut d, id) = doc_with_rect(); d.node_mut(id).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); let mut s = Selection::default(); @@ -159,13 +528,12 @@ mod tests { assert!(GradientTool::default().overlays(&cx).is_empty()); cx.fill_active = false; // The stroke's annotator spans the stroke-inflated box (1 pt stroke: 99.5..200.5). - let (a, b, stops) = annotator(&cx).unwrap(); - assert_eq!((a, b, stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2)); + let a = Annotator::of(&cx).unwrap(); + assert_eq!((a.geom.start, a.geom.end, a.gradient.stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2)); let mut t = GradientTool::default(); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 150.0)); - let Action::Preview(_, v) = &a[1] else { panic!("{a:?}") }; - assert_eq!(v["stroke"], json!(true)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 120.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 120.0)); + assert_eq!(preview(&a)["stroke"], json!(true)); } #[test] @@ -179,4 +547,156 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); } + + #[test] + fn the_end_handle_changes_only_the_end_and_the_start_moves_both() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(220.0, 170.0)))).clone(); + assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(100.0, 150.0), Point::new(220.0, 170.0))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(220.0, 170.0))), vec![Action::Commit]); + // The start handle moves the whole vector. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 150.0))); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(110.0, 140.0)))).clone(); + assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(110.0, 140.0), Point::new(210.0, 140.0))); + } + + #[test] + fn past_the_end_rotates_and_shift_snaps() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert_eq!(t.cursor(&cx, Point::new(210.0, 151.0), Mods::default()), Cursor::Rotate); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(210.0, 151.0))); + let end = point(&preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, Point::new(103.0, 60.0)))))["end"]); + // Snapped to straight up, keeping the 100 pt length. + assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}"); + } + + #[test] + fn a_bar_click_adds_a_stop_and_selects_it() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert_eq!(t.cursor(&cx, Point::new(130.0, 150.0), Mods::default()), Cursor::AddStop); + assert!(t.pointer(&cx, &ev(PointerKind::Down, Point::new(130.0, 150.0))).is_empty()); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0))); + let (c, v) = exec(&a[0]); + assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0])); + assert_eq!(a[1], GradientTool::select_stop(1)); + } + + #[test] + fn stops_drag_along_the_bar_off_it_to_delete_and_alt_copies() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 0).unwrap(); + let mut t = GradientTool::default(); + // Pressing a stop selects it; dragging moves it. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, chip)), vec![GradientTool::select_stop(0)]); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(140.0, chip.y))))), vec![40.0, 100.0]); + assert_eq!(t.cursor(&cx, chip, Mods::default()), Cursor::Move); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(140.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(0)]); + // Two stops never lose one: dragged off the bar, the stop stays. + t.pointer(&cx, &ev(PointerKind::Down, chip)); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 260.0))))), vec![0.0, 100.0]); + t.pointer(&cx, &ev(PointerKind::Up, Point::new(100.0, 260.0))); + // Alt-drag leaves the original and drags a copy. + t.pointer(&cx, &with(ALT, ev(PointerKind::Down, chip))); + assert_eq!(offsets(preview(&t.pointer(&cx, &with(ALT, ev(PointerKind::Drag, Point::new(150.0, chip.y)))))), vec![0.0, 50.0, 100.0]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(1)]); + + // With three stops, dragging the middle one off the bar deletes it. + let (d, s) = graded(three()); + let cx = crate::testutil::cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); + t.pointer(&cx, &ev(PointerKind::Down, chip)); + assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(150.0, 200.0))))), vec![0.0, 100.0]); + assert_eq!(t.cursor(&cx, Point::new(150.0, 200.0), Mods::default()), Cursor::RemoveStop); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 200.0))), vec![Action::Commit, GradientTool::select_stop(1)]); + } + + #[test] + fn diamonds_move_midpoints() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let diamond = Annotator::of(&cx).unwrap().mid_point(&cx, 0).unwrap(); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, diamond)); + let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(130.0, diamond.y)))).clone(); + assert!((v["stops"][0]["midpoint"].as_f64().unwrap() - 0.3).abs() < 1e-6, "{v}"); + } + + #[test] + fn delete_and_arrows_act_on_the_selected_stop() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + assert!(!t.claims_key(&cx, ToolKey::Delete), "no stop selected"); + cx.gradient_stop = Some(1); + assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace) && !t.claims_key(&cx, ToolKey::Enter)); + // Two stops: Delete is claimed but does nothing. + assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); + let a = t.key(&cx, ToolKey::Left, SHIFT); + let (c, v) = exec(&a[0]); + assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 90.0])); + assert_eq!(a[1], GradientTool::select_stop(1)); + // With three stops Delete removes the selected one and selects its successor. + let (d, s) = graded(three()); + let mut cx = crate::testutil::cx(&d, &s, &p); + cx.gradient_stop = Some(1); + let a = t.key(&cx, ToolKey::Backspace, Mods::default()); + assert_eq!(offsets(exec(&a[0]).1), vec![0.0, 100.0]); + assert_eq!(a[1], GradientTool::select_stop(1)); + } + + #[test] + fn a_click_inside_the_art_applies_the_gradient_from_there() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 120.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 120.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + // The default fit's 100 pt horizontal vector, starting at the click. + assert_eq!((point(&v["start"]), point(&v["end"]), &v["ids"]), (Point::new(150.0, 120.0), Point::new(250.0, 120.0), &json!([id.0]))); + // Outside the art a click does nothing. + t.pointer(&cx, &ev(PointerKind::Down, Point::new(400.0, 400.0))); + assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); + } + + #[test] + fn double_click_on_a_stop_opens_its_popover() { + let (d, s) = graded(two()); + let p = paint(); + let cx = cx(&d, &s, &p); + let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap(); + let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, chip)); + assert_eq!(a[0], GradientTool::select_stop(1)); + assert_eq!(a[1], Action::Dialog("gradientStop".into(), json!({"index": 1, "x": chip.x, "y": chip.y}))); + } + + #[test] + fn overlays_draw_a_chip_per_stop_marking_the_selected_one() { + let (d, s) = graded(two()); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.gradient_stop = Some(1); + let o = GradientTool::default().overlays(&cx); + let chips: Vec = o.iter().filter_map(|o| if let Overlay::Swatch { selected, .. } = o { Some(*selected) } else { None }).collect(); + assert_eq!(chips, vec![false, true]); + assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9fd87fa8..7382f4f2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -335,6 +335,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ZoomOut => C::ZoomOut, Cursor::Eyedropper => C::Crosshair, Cursor::NotAllowed => C::NotAllowed, + Cursor::AddStop => C::Copy, + Cursor::RemoveStop => C::NotAllowed, } } @@ -879,6 +881,17 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { p.rect_filled(r, CornerRadius::same(3), t.measure_bg); p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); } + Overlay::Swatch { p: pt, color, selected } => { + // A white disc under the colour shows its opacity; a dark rim keeps it readable on + // any art, and the accent ring marks the selected stop. + let c = xf.to_screen(*pt); + p.circle_filled(c, 6.0, Color32::WHITE); + p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3])); + p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32))); + if *selected { + p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent)); + } + } } } } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index ef378b84..45c817c1 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -107,6 +107,17 @@ fn corner_radius(p: &Painter, o: Pos2) { p.add(Shape::line(pts, Stroke::new(1.2, INK))); } +/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it) +/// badge. +fn stop_badge(p: &Painter, o: Pos2, add: bool) { + arrow(p, o, false); + let b = o + vec2(13.0, 13.0); + line(p, b, b + vec2(6.0, 0.0)); + if add { + line(p, b + vec2(3.0, -3.0), b + vec2(3.0, 3.0)); + } +} + fn ibeam(p: &Painter, o: Pos2) { line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0)); line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0)); @@ -137,6 +148,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { Cursor::PenClose => pen(painter, p, "o"), Cursor::PenContinue => pen(painter, p, "/"), Cursor::Text => ibeam(painter, p), + Cursor::AddStop => stop_badge(painter, p, true), + Cursor::RemoveStop => stop_badge(painter, p, false), _ => return false, } let _ = pos2; diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs new file mode 100644 index 00000000..53bc7db2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -0,0 +1,251 @@ +//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it +//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and +//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and +//! closes on Escape or a click elsewhere. +//! +//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`). +//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm. + +use egui::{Sense, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::gradient::move_stop; +use vectorcraft_color::{Color, GradientStop, Paint}; +use vectorcraft_geom::Point; + +use super::{DialogResult, DialogSpec}; +use crate::VectorcraftApp; +use crate::canvas::Xf; +use crate::panels::gradient::stops_json; +use crate::panels::{active_paint, live_run}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::{self, Live}; + +pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); + +/// The stops of the gradient behind the active proxy and the selected stop's index. +fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { + let Paint::Gradient(g) = active_paint(app) else { return None }; + let i = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len())?; + Some((g.gradient.stops, i)) +} + +/// Write `stops` to the gradient and select stop `i`. +fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) { + let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active }); + live_run(app, "Gradient", "paint.editGradient", params, phase); + if phase == Live::Released && app.session.gradient_stop != Some(i) { + app.run("gradient.selectStop", json!({ "index": i })).ok(); + } +} + +/// `stops` with stop `i` given `color`. +fn recolor(mut stops: Vec, i: usize, color: Color) -> Vec { + stops[i].color = color; + stops +} + +/// Solid colours of the document's swatches (groups included), with their names. +fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> { + let Some(st) = app.session.active() else { return vec![] }; + let d = &st.doc; + d.swatches.iter().chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())).filter_map(|s| Some((s.name.clone(), s.paint.color()?))).collect() +} + +fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(d) = app.ui.dialog.clone() else { return }; + // The stop went away (undo, another object selected): nothing left to edit. + let Some((stops, i)) = selected(app) else { + app.ui.dialog = None; + return; + }; + let t = Tokens::get(ctx); + let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); + let pos = + app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0)); + let swatches = d.str("tab") == "swatches"; + let mut tab = None; + let resp = egui::Area::new(egui::Id::new("gradient-stop-popover")) + .order(egui::Order::Foreground) + .fixed_pos(pos) + .constrain(true) + .show(ctx, |ui| { + egui::Frame::popup(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(250.0); + ui.horizontal(|ui| { + for (icon, tip, on) in [("palette", "Color", !swatches), ("swatch-book", "Swatches", swatches)] { + if widgets::icon_button(ui, icon, tip, on, 24.0).clicked() { + tab = Some(if icon == "palette" { "color" } else { "swatches" }); + } + } + }); + ui.add_space(6.0); + if swatches { + swatch_grid(app, ui, &stops, i); + } else { + // The Color panel edits the selected stop of the active gradient. + crate::panels::color::show(app, ui); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let s = stops[i]; + widgets::dim_label(ui, "Opacity:"); + if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) { + let mut v2 = stops.clone(); + v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32; + apply(app, &v2, i, Live::Released); + } + widgets::dim_label(ui, "Location:"); + if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) { + let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32); + apply(app, &v2, ni, Live::Released); + } + }); + }); + }) + .response; + if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) { + d.fields.insert("tab".into(), json!(tab)); + } + if resp.clicked_elsewhere() { + app.ui.dialog = None; + } +} + +/// Document swatches as tiles: a click gives the stop that colour. +fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientStop], i: usize) { + let colors = swatch_colors(app); + if colors.is_empty() { + widgets::dim_label(ui, "The document has no colour swatches."); + return; + } + let mut pick = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for (name, c) in &colors { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered()); + if resp.on_hover_text(name).clicked() { + pick = Some(*c); + } + } + }); + if let Some(c) = pick { + apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released); + } +} + +/// OK (agents): apply the `color`, `opacity` and `location` fields that are set, then close. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?; + if let Some(hex) = d.fields.get("color").and_then(Value::as_str) { + let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?; + stops = recolor(stops, i, c); + } + if d.fields.contains_key("opacity") { + stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32; + } + if d.fields.contains_key("location") { + (stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32); + } + apply(app, &stops, i, Live::Released); + app.ui.dialog = None; + Ok(json!({ "index": i })) +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + use super::*; + + /// A rectangle with a three-stop gradient from (100, 150) to (200, 150), the Gradient tool + /// and stop 1 selected. + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [100, 150], "end": [200, 150]}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + app + } + + fn stops(app: &VectorcraftApp) -> Vec { + selected(app).unwrap().0 + } + + /// One headless frame of the dialog layer and the shortcuts. + fn frame(app: &mut VectorcraftApp, events: Vec) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::super::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn key(k: egui::Key) -> egui::Event { + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() } + } + + #[test] + fn double_clicking_a_stop_opens_the_popover_which_draws_both_tabs() { + let mut app = app(); + let view = app.view_info(); + let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom); + crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view); + let d = app.ui.dialog.clone().expect("the popover opened"); + assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into())); + assert_eq!(app.session.gradient_stop, Some(1)); + frame(&mut app, vec![]); + app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches")); + frame(&mut app, vec![]); + assert!(app.ui.dialog.is_some(), "still open"); + // Escape closes it. + frame(&mut app, vec![key(egui::Key::Escape)]); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn confirm_applies_colour_opacity_and_location() { + let mut app = app(); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160, "color": "#ff0000", "opacity": 40, "location": 80}))); + assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": 1})); + let s = stops(&app); + assert_eq!((s[1].color.to_hex(), s[1].opacity, (s[1].offset * 100.0).round()), ("#ff0000".to_string(), 0.4, 80.0)); + assert!(app.ui.dialog.is_none()); + // Moving it past the last stop keeps it selected at its new index. + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100}))); + super::super::confirm(&mut app).unwrap(); + assert_eq!((app.session.gradient_stop, stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string())); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"}))); + assert!(super::super::confirm(&mut app).is_err()); + } + + #[test] + fn the_popover_closes_when_its_stop_goes_away() { + let mut app = app(); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160}))); + app.run("gradient.selectStop", json!({"index": null})).unwrap(); + frame(&mut app, vec![]); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn delete_removes_the_selected_stop_instead_of_the_object() { + let mut app = app(); + frame(&mut app, vec![key(egui::Key::Delete)]); + assert_eq!(stops(&app).len(), 2); + frame(&mut app, vec![key(egui::Key::Backspace)]); + assert_eq!(stops(&app).len(), 2, "never below two stops"); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle stays"); + // The arrows nudge it. + let before = stops(&app)[1].offset; + frame(&mut app, vec![key(egui::Key::ArrowLeft)]); + assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9532e989..2f50d397 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -14,6 +14,7 @@ mod document_setup; mod effect; mod export_for_screens; mod form; +mod gradient_stop; mod new_document; mod path_ops; mod recolor; @@ -127,6 +128,7 @@ registry! { Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), + GradientStop: ["gradientStop"] => gradient_stop::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 0016f60a..b65f431e 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -1,5 +1,5 @@ -//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options -//! and artboard options. +//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options, +//! artboard options and the gradient stop popover. use serde_json::{Value, json}; @@ -47,6 +47,8 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } Dialog::new(kind, base) } + // Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip. + "gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})), _ => return, }; app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index eb2f44db..412dd9aa 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -159,11 +159,11 @@ enum Target { Stop { paint: Paint, index: usize, color: Color }, } -fn target(app: &VectorcraftApp, ui: &Ui) -> Target { +fn target(app: &VectorcraftApp) -> Target { let p = active_paint(app); match &p { Paint::Gradient(g) => { - let i = super::gradient::selected_stop(ui.ctx()).unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); + let i = app.session.gradient_stop.unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK); Target::Stop { paint: p.clone(), index: i, color } } @@ -196,7 +196,7 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live) pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let tgt = target(app, ui); + let tgt = target(app); let color = match &tgt { Target::Paint(c) => *c, Target::Stop { color, .. } => Some(*color), @@ -376,7 +376,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "color-hide-options", !hidden); } ui.separator(); - let tgt = target(app, ui); + let tgt = target(app); let color = match &tgt { Target::Paint(c) => *c, Target::Stop { color, .. } => Some(*color), diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index d336afd8..09371949 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -4,89 +4,30 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint}; +use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use super::{active_paint, live_run, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, Live, menu_item}; use crate::{VectorcraftApp, icons}; -/// Minimum number of stops (Illustrator won't delete below two). -pub const MIN_STOPS: usize = 2; /// Dragging a stop this far below the ramp removes it. pub const REMOVE_DISTANCE: f32 = 28.0; -/// The Gradient panel's selected stop (shared with the Color panel). -pub fn selected_stop(ctx: &egui::Context) -> Option { - pstate::>(ctx, "grad-stop") -} -fn select_stop(ctx: &egui::Context, i: Option) { - set_pstate(ctx, "grad-stop", i); +/// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the +/// Color panel and agents). Run after the edit that creates it. +fn select_stop(app: &mut VectorcraftApp, i: usize) { + app.run("gradient.selectStop", json!({ "index": i })).ok(); } -// ---------- pure stop math (unit-tested) ---------- - -/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and -/// the new stop's index. -pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec, usize) { - let offset = offset.clamp(0.0, 1.0); - let (color, opacity) = g.sample(offset); - let mut stops = g.stops.clone(); - let idx = stops.iter().position(|s| s.offset > offset).unwrap_or(stops.len()); - stops.insert(idx, GradientStop { offset, color, opacity, midpoint: 0.5 }); - (stops, idx) -} - -/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`]. -pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option> { - if stops.len() <= MIN_STOPS || i >= stops.len() { - return None; - } - let mut v = stops.to_vec(); - v.remove(i); - Some(v) -} - -/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index. -pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec, usize) { - let mut v = stops.to_vec(); - if i >= v.len() { - return (v, i); - } - let mut s = v.remove(i); - s.offset = offset.clamp(0.0, 1.0); - let idx = v.iter().position(|o| o.offset > s.offset).unwrap_or(v.len()); - v.insert(idx, s); - (v, idx) -} - -/// Set the midpoint between stop `i` and `i + 1` (clamped to Illustrator's 13–87 %). -pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec { - let mut v = stops.to_vec(); - if let Some(s) = v.get_mut(i) { - s.midpoint = m.clamp(0.13, 0.87); - } - v -} +// ---------- pure ramp math (unit-tested) ---------- /// Offset (0..1) of an x position on a ramp spanning `left..left + width`. pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 { ((x - left) / width.max(1.0)).clamp(0.0, 1.0) } -/// Absolute position (0..1) of the midpoint diamond after stop `i`. -pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option { - let (a, b) = (stops.get(i)?, stops.get(i + 1)?); - Some(a.offset + (b.offset - a.offset) * a.midpoint) -} - -/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position. -pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option { - let (a, b) = (stops.get(i)?, stops.get(i + 1)?); - let span = (b.offset - a.offset).max(1e-6); - Some(((pos - a.offset) / span).clamp(0.13, 0.87)) -} - /// Stops as `paint.editGradient` JSON. pub use vectorcraft_tools::params::stops_json; @@ -184,7 +125,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ramp(app, ui, &g.gradient, is_grad); ui.add_space(4.0); // Stop fields. - let sel = selected_stop(ui.ctx()).filter(|i| *i < g.gradient.stops.len()); + let sel = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len()); ui.horizontal(|ui| { let enabled = is_grad && sel.is_some(); let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied(); @@ -205,16 +146,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && let Some(i) = sel { let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32); - select_stop(ui.ctx(), Some(ni)); edit(app, json!({"stops": stops_json(&stops)}), Live::Released); + select_stop(app, ni); } let can_del = enabled && g.gradient.stops.len() > MIN_STOPS; if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked() && let Some(i) = sel && let Some(stops) = remove_stop(&g.gradient.stops, i) { - select_stop(ui.ctx(), Some(i.min(stops.len() - 1))); edit(app, json!({"stops": stops_json(&stops)}), Live::Released); + select_stop(app, i.min(stops.len() - 1)); } }); if let Some(i) = sel @@ -269,7 +210,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { } ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let x_of = |o: f32| bar.left() + o * bar.width(); - let sel = selected_stop(ui.ctx()); + let sel = app.session.gradient_stop; let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); let stops = &g.stops; // While dragging, edits are computed against the stops as they were when the drag began (the @@ -277,6 +218,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let origin: Vec = if drag == Drag::None { stops.clone() } else { pstate::>(ui.ctx(), "grad-origin") }; let origin = if origin.len() == stops.len() { origin } else { stops.clone() }; let mut changed: Option<(Vec, Live)> = None; + // The stop to select once `changed` is applied. + let mut select: Option = None; // Midpoint diamonds. for i in 0..stops.len().saturating_sub(1) { let Some(p) = midpoint_pos(stops, i) else { continue }; @@ -325,7 +268,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { continue; } if resp.clicked() || resp.drag_started() { - select_stop(ui.ctx(), Some(i)); + select = Some(i); } if resp.drag_started() { drag = Drag::Stop(i); @@ -340,12 +283,12 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { drag = Drag::None; if removing { if let Some(v) = remove_stop(&origin, i) { - select_stop(ui.ctx(), Some(i.min(v.len() - 1))); + select = Some(i.min(v.len() - 1)); changed = Some((v, Live::Released)); } } else { let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width())); - select_stop(ui.ctx(), Some(ni)); + select = Some(ni); changed = Some((v, Live::Released)); } } else if removing { @@ -369,7 +312,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { { if is_grad { let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width())); - select_stop(ui.ctx(), Some(i)); + select = Some(i); changed = Some((v, Live::Released)); } else { edit(app, json!({}), Live::Released); @@ -383,6 +326,9 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { if let Some((v, phase)) = changed { edit(app, json!({"stops": stops_json(&v)}), phase); } + if let Some(i) = select { + select_stop(app, i); + } } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -400,72 +346,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let d = Gradient::default(); edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released); } - let _ = Color::BLACK; } #[cfg(test)] mod tests { use super::*; - fn g3() -> Gradient { - let mut g = Gradient::default(); - g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }); - g - } - - #[test] - fn insert_samples_color_and_sorts() { - let g = Gradient::default(); - let (stops, i) = insert_stop(&g, 0.25); - assert_eq!(stops.len(), 3); - assert_eq!(i, 1); - let r = stops[1].color.to_rgb()[0]; - assert!((r - 0.75).abs() < 1e-4, "sampled {r}"); - let (stops, i) = insert_stop(&g, 2.0); - assert_eq!(i, 2); - assert_eq!(stops[2].offset, 1.0); - } - - #[test] - fn remove_keeps_two() { - let g = g3(); - let v = remove_stop(&g.stops, 1).unwrap(); - assert_eq!(v.len(), 2); - assert!(remove_stop(&v, 0).is_none()); - assert!(remove_stop(&g.stops, 9).is_none()); - } - - #[test] - fn move_reorders_and_tracks_index() { - let g = g3(); - let (v, i) = move_stop(&g.stops, 0, 0.8); - assert_eq!(i, 1); - assert_eq!(v[1].color.to_hex(), "#ffffff"); - assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset)); - let (v, i) = move_stop(&g.stops, 1, -3.0); - // Clamped to 0 and placed after the existing stop at 0. - assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string())); - } - - #[test] - fn midpoint_math() { - let g = g3(); - assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25)); - assert_eq!(midpoint_pos(&g.stops, 2), None); - let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap(); - assert!((m - 0.2).abs() < 1e-5); - assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13); - let v = set_midpoint(&g.stops, 0, 0.99); - assert_eq!(v[0].midpoint, 0.87); - } - #[test] fn offsets_and_json() { assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0); assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0); - let j = stops_json(&g3().stops); - assert_eq!(j.as_array().unwrap().len(), 3); + let j = stops_json(&Gradient::default().stops); + assert_eq!(j.as_array().unwrap().len(), 2); assert_eq!(j[1]["midpoint"], json!(0.5)); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4398bb06..a5849a4d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -144,6 +144,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } return; } + // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: + // Delete/Backspace remove it, ←/→ nudge it). + let m = ctx.input(|i| i.modifiers); + for (k, tk) in + [(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] + { + if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) { + let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + return; + } + } // Command shortcuts. let mut fire: Option<&'static str> = None; for (sc, id) in all_shortcuts() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9f7d6c78..ab1ea821 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,3 +19,4 @@ | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | diff --git a/docs/mcp.md b/docs/mcp.md index 50b51256..d5203d6c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -106,6 +106,18 @@ Drive a tool like a mouse: {"kind":"down","x":100,"y":100},{"kind":"drag","x":150,"y":140},{"kind":"up","x":200,"y":180}]}} ``` +Edit a gradient with the Gradient tool's annotator (the bar runs along the vector; stop chips sit 10 px under +it, midpoint diamonds 7 px above): a click on the bar adds a stop, dragging a chip moves it (drag it off the bar to +delete it, hold Alt to copy it), dragging the end handle changes the vector. The selected stop +(`gradient.selectStop`) takes Delete and ←/→: + +```json +{"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"start":[100,150],"end":[200,150]}}}} +{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":130,"y":150},{"kind":"up","x":130,"y":150}]}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":130,"y":160},{"kind":"drag","x":160,"y":160},{"kind":"up","x":160,"y":160}]}} +{"name":"press_key","arguments":{"key":"Delete"}} +``` + Raw protocol (for debugging): ```sh From d3dc4a2af307f4148177bb34e75014a3e5f02eee Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:52:28 +0700 Subject: [PATCH 5/7] M3.17: the stop popover survives its opening double-click, and the selected stop belongs to its gradient The double-click that opens the popover lands outside it in the same frame, so the popover closed as soon as it opened; only a later click elsewhere closes it now. The selected stop remembers the document, first selected object and proxy it was picked on (Session::selected_stop), so selecting other art or toggling Fill/Stroke no longer lets Delete or the arrows edit a stop on a different gradient. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/gradient.rs | 27 ++++++++++++++++++- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 7 ++--- crates/engine/src/tests_gradient.rs | 28 +++++++++++++++++--- crates/engine/src/tooling.rs | 2 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 29 +++++++++++++++++---- crates/ui-egui/src/panels/color.rs | 2 +- crates/ui-egui/src/panels/gradient.rs | 4 +-- 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 37e94859..938e7d34 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -71,10 +71,35 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { Some(i) } }; - s.gradient_stop = index; + s.gradient_stop = index.map(|i| (i, StopOwner::of(s))); Ok(json!({ "index": index })) } +/// Whose gradient a selected stop belongs to: the active document, its first selected object +/// (None: the default paint) and the proxy in front. Selecting other art or toggling the proxy +/// leaves no stop selected. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StopOwner { + doc: Option, + object: Option, + fill: bool, +} + +impl StopOwner { + fn of(s: &Session) -> Self { + Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active } + } +} + +impl Session { + /// The selected gradient stop (`gradient.selectStop`), while the gradient it was selected on + /// is still the one behind the active proxy. Callers check it against the stop count (an undo + /// can remove stops). + pub fn selected_stop(&self) -> Option { + self.gradient_stop.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i) + } +} + type Parsed = std::result::Result; /// Parse a kind name. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11231ac4..e1e1818e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,7 @@ mod edit; mod effectcmd; pub mod fileio; mod fonts; -mod gradient; +pub(crate) mod gradient; pub(crate) mod graph; pub mod help; mod layer; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc962147..9b182b9e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -443,9 +443,10 @@ pub struct Session { untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, - /// The selected gradient stop (`gradient.selectStop`), shared by the Gradient tool's annotator, - /// the Gradient and Color panels and agents. - pub gradient_stop: Option, + /// The selected gradient stop (`gradient.selectStop`) and whose gradient it was selected on, + /// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it + /// with [`Session::selected_stop`]. + pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, } impl Default for Session { diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 934e8b5b..19964c9f 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -285,12 +285,12 @@ fn stop_count(s: &Session, id: NodeId) -> usize { fn select_stop_is_validated_and_shared() { let (mut s, _) = annotated(); assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1})); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops"); assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err()); assert!(s.execute("gradient.selectStop", &json!({})).is_err()); s.execute("gradient.selectStop", &json!({"index": null})).unwrap(); - assert_eq!(s.gradient_stop, None); + assert_eq!(s.selected_stop(), None); // The stroke proxy's paint is solid: nothing to select there. s.execute("paint.toggleActive", &json!({})).unwrap(); assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err()); @@ -304,7 +304,7 @@ fn annotator_gestures_edit_the_gradient_as_single_undo_steps() { let stops = fill_gradient(&s, id).gradient.stops; assert_eq!(stops.len(), 3); assert!((stops[1].offset - 0.3).abs() < 1e-6); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); // Dragging the end handle changes only the end, as one undo step. let undo = s.doc().unwrap().history.undo.len(); gesture( @@ -320,9 +320,29 @@ fn annotator_gestures_edit_the_gradient_as_single_undo_steps() { assert!(s.tool_claims_key(ToolKey::Delete, view)); s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap(); assert_eq!(stop_count(&s, id), 2); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); // Never below two stops. s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap(); assert_eq!(stop_count(&s, id), 2); assert!(s.doc().unwrap().doc.node(id).is_some()); } + +#[test] +fn the_selected_stop_belongs_to_its_gradient() { + let (mut s, a) = annotated(); + let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(); + let view = crate::ViewInfo::default(); + assert!(s.tool_claims_key(ToolKey::Delete, view)); + // Other art selected: no stop is selected there, so Delete is not the tool's. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.selected_stop(), None); + assert!(!s.tool_claims_key(ToolKey::Delete, view)); + // Back on the art it was selected on, it is selected again; the other proxy has none. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); + s.execute("paint.toggleActive", &json!({})).unwrap(); + assert_eq!(s.selected_stop(), None); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 87d13fdf..d0ca84df 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -79,7 +79,7 @@ impl Session { snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, fill_active: self.fill_active, - gradient_stop: self.gradient_stop, + gradient_stop: self.selected_stop(), }; f(self.tool.as_mut(), &cx) } diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 53bc7db2..9ea5772c 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -26,7 +26,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); /// The stops of the gradient behind the active proxy and the selected stop's index. fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { let Paint::Gradient(g) = active_paint(app) else { return None }; - let i = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len())?; + let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?; Some((g.gradient.stops, i)) } @@ -34,7 +34,7 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) { let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active }); live_run(app, "Gradient", "paint.editGradient", params, phase); - if phase == Live::Released && app.session.gradient_stop != Some(i) { + if phase == Live::Released && app.session.selected_stop() != Some(i) { app.run("gradient.selectStop", json!({ "index": i })).ok(); } } @@ -107,7 +107,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) { d.fields.insert("tab".into(), json!(tab)); } - if resp.clicked_elsewhere() { + // The double-click that opened the popover lands on its chip, outside it: only a later click + // elsewhere closes it. + if resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) { app.ui.dialog = None; } } @@ -200,7 +202,7 @@ mod tests { crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view); let d = app.ui.dialog.clone().expect("the popover opened"); assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into())); - assert_eq!(app.session.gradient_stop, Some(1)); + assert_eq!(app.session.selected_stop(), Some(1)); frame(&mut app, vec![]); app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches")); frame(&mut app, vec![]); @@ -221,11 +223,28 @@ mod tests { // Moving it past the last stop keeps it selected at its new index. app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100}))); super::super::confirm(&mut app).unwrap(); - assert_eq!((app.session.gradient_stop, stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string())); + assert_eq!((app.session.selected_stop(), stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string())); app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"}))); assert!(super::super::confirm(&mut app).is_err()); } + fn click(at: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + #[test] + fn the_opening_double_click_keeps_it_open_and_a_later_click_elsewhere_closes_it() { + let mut app = app(); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160}))); + // The frame the double-click lands in (the popover opens beside the pointer, not under it). + let at = egui::pos2(5.0, 5.0); + let double = vec![egui::Event::PointerMoved(at), click(at, true), click(at, false), click(at, true), click(at, false)]; + frame(&mut app, double); + assert!(app.ui.dialog.is_some(), "the opening double-click must not close it"); + frame(&mut app, vec![egui::Event::PointerMoved(at), click(at, true), click(at, false)]); + assert!(app.ui.dialog.is_none(), "a click elsewhere closes it"); + } + #[test] fn the_popover_closes_when_its_stop_goes_away() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 412dd9aa..e9fbf897 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -163,7 +163,7 @@ fn target(app: &VectorcraftApp) -> Target { let p = active_paint(app); match &p { Paint::Gradient(g) => { - let i = app.session.gradient_stop.unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); + let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK); Target::Stop { paint: p.clone(), index: i, color } } diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 09371949..80583ce0 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -125,7 +125,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ramp(app, ui, &g.gradient, is_grad); ui.add_space(4.0); // Stop fields. - let sel = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len()); + let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()); ui.horizontal(|ui| { let enabled = is_grad && sel.is_some(); let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied(); @@ -210,7 +210,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { } ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let x_of = |o: f32| bar.left() + o * bar.width(); - let sel = app.session.gradient_stop; + let sel = app.session.selected_stop(); let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); let stops = &g.stops; // While dragging, edits are computed against the stops as they were when the drag began (the From cca1d1103244735e9cc92c31c3e5c739311ae871 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:58:05 +0700 Subject: [PATCH 6/7] M3.14: gradient swatches and new art fit a placed gradient to themselves Lossless gradient params carried a saved gradient's vector (document coordinates of the art it came from) into swatch applications and the default paint, so a swatch applied elsewhere, or the next shape drawn, got a gradient placed off the object. Applying a gradient swatch now fits it to each object (keeping its aspect, on type in text space), and the default for new art keeps all but the placement. Saving a swatch stays lossless. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/gradient.rs | 35 +++++++++++++++++++++++------ crates/engine/src/cmd/paint.rs | 9 ++++---- crates/engine/src/tests_gradient.rs | 32 ++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 938e7d34..d31edd85 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -167,17 +167,37 @@ fn aspect_param(p: &Value) -> Parsed> { .transpose() } -/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is -/// placed on the bounds so the aspect sticks. -pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { - match (paint, p.get("gradient").and_then(|g| g.get("aspect"))) { - (Paint::Gradient(g), Some(a)) if g.geom.is_none() => { - apply_gradient_edit(paint, &json!({ "aspect": a }), bounds).unwrap_or_else(|_| paint.clone()) - } +/// `paint` without the placement it had on the art it came from (a swatch, or the default paint +/// for new art): a placed gradient keeps its angle and fits each object it lands on. +pub(crate) fn unplaced(paint: &Paint) -> Paint { + match paint { + Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })), _ => paint.clone(), } } +/// Is `p` applying a swatch (whose gradient placement belongs to the art it was saved from)? +fn applies_swatch(p: &Value) -> bool { + p.get("swatch").is_some() +} + +/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is +/// placed on the bounds so the aspect sticks, and a gradient swatch fits the object (keeping its +/// aspect) instead of the art it was saved from. +pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { + let Paint::Gradient(g) = paint else { return paint.clone() }; + let aspect = match g.geom { + Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)), + None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(), + Some(_) => return paint.clone(), + }; + let fitted = unplaced(paint); + match aspect { + Some(a) => apply_gradient_edit(&fitted, &json!({ "aspect": a }), bounds).unwrap_or(fitted), + None => fitted, + } +} + /// Apply the gradient edits in `p` to `paint` (pure; unit-tested). pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { let mut gp = match paint { @@ -308,6 +328,7 @@ pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect let mut out = place_paint(paint, p, Some(bounds)); if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out) && src.geom.is_some() + && !applies_swatch(p) && let Some(inv) = invert(xf) { g.transform(inv); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e958c1de..6d21a454 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; -use super::gradient::{place_paint, place_run_paint, run_paint_mut}; +use super::gradient::{place_paint, place_run_paint, run_paint_mut, unplaced}; use super::*; use crate::EngineError; @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (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), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (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), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -75,10 +75,11 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?; + // New art gets the paint fitted to itself, not placed where this one is. if fill { - s.paint.fill = paint.clone(); + s.paint.fill = unplaced(&paint); } else { - s.paint.stroke = paint.clone(); + s.paint.stroke = unplaced(&paint); } s.fill_active = fill; let ids = paint_targets(s, p)?; diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 19964c9f..5db0f87b 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -61,8 +61,9 @@ fn gradient_params_round_trip_through_fill_and_swatches() { assert_eq!(got.gradient.stops, g.gradient.stops); let (a, b) = (got.geom.unwrap(), g.geom.unwrap()); assert!(a.start.distance(b.start) < 1e-9 && a.end.distance(b.end) < 1e-9 && (a.aspect - b.aspect).abs() < 1e-9, "{a:?}"); - // The default proxy keeps it too, and saving it as a swatch is lossless. - assert_eq!(s.paint.fill, Paint::Gradient(Box::new(got.clone()))); + // The default for new art keeps all but the placement (new art fits it to itself), and saving + // it as a swatch is lossless. + assert_eq!(s.paint.fill, Paint::Gradient(Box::new(GradientPaint { geom: None, ..got.clone() }))); let mut q = gradient_param(&g); q["name"] = json!("Three"); s.execute("swatch.new", &q).unwrap(); @@ -346,3 +347,30 @@ fn the_selected_stop_belongs_to_its_gradient() { s.execute("paint.toggleActive", &json!({})).unwrap(); assert_eq!(s.selected_stop(), None); } + +#[test] +fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let mut g = three_stops(); + g.gradient.kind = GradientKind::Radial; + let mut q = gradient_param(&g); + s.execute("paint.setFill", &q).unwrap(); + q["name"] = json!("Placed"); + s.execute("swatch.new", &q).unwrap(); + // New art drawn elsewhere fits the gradient to itself rather than to the first rectangle. + let b = rect(&mut s, 500.0, 300.0, 100.0, 100.0); + assert_eq!(fill_gradient(&s, b).geom, None); + // Applying the swatch fits its gradient to each object, keeping the aspect it was saved with. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); + let geom = fill_geom(&s, b); + assert_eq!(geom.start, Point::new(550.0, 350.0)); + assert!((geom.aspect - 0.5).abs() < 1e-9 && (geom.length() - 50.0).abs() < 1e-9, "{geom:?}"); + // On type, in text space. + let t = text(&mut s, 100.0, 400.0); + s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); + let (_, doc) = node(&s, t).proxy_gradient(false).unwrap(); + let tb = node(&s, t).geometric_bounds().unwrap(); + assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}"); +} From 2ed962e7d01b2a0eadaae42caf032f58494c22c1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:59:03 +0700 Subject: [PATCH 7/7] M3.16: the eyedropper samples type gradients in text space Shift-sampling a type object's run gradient projected the document point onto a vector kept in text space, so placed gradients on moved or turned type sampled the wrong colour. The eyedropper now reads the paint through Node::proxy_paint and samples in the paint's own space and box (text space for runs, the stroke-inflated box for strokes). Co-Authored-By: Claude Opus 5.5 --- crates/tools/src/xform/eyedropper.rs | 35 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 9307953e..71891418 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -42,15 +42,11 @@ impl Tool for EyedropperTool { let src = paint_owner(cx.doc, h.leaf); let Some(n) = cx.doc.node(src) else { return vec![] }; if ev.mods.shift { - let (fill, stroke) = match &n.kind { - vectorcraft_doc::NodeKind::Text(t) => { - let st = t.runs.first().map(|r| r.style.clone()); - (st.as_ref().map(|s| s.fill.clone()).unwrap_or_default(), st.map(|s| s.stroke).unwrap_or_default()) - } - _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - }; - let paint = if h.kind == HitKind::Stroke || fill.is_none() { stroke } else { fill }; - return match paint_color_at(&paint, n.geometric_bounds(), ev.pos) { + let fill_none = n.proxy_paint(false).is_none_or(|(p, ..)| p.is_none()); + let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none) else { return vec![] }; + // Sample in the paint's own space (text space for type runs) and box. + let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos }; + return match paint_color_at(paint, Some(bounds), p) { Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))], None => vec![], }; @@ -96,6 +92,27 @@ mod tests { assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000"); } + #[test] + fn type_gradients_are_sampled_in_text_space() { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + // Type turned 90°: its baseline runs down from (100, 100). + let mut t = TextObject::point(Point::ZERO, "Gradient", CharStyle { size: 20.0, ..Default::default() }); + t.xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(std::f64::consts::FRAC_PI_2); + let mut g = GradientPaint::new(Gradient::default()); + g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0 }); + t.runs[0].style.fill = Paint::Gradient(Box::new(g)); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let (s, p) = (Selection::default(), paint()); + let shift = Mods { shift: true, ..Default::default() }; + // 80 % along the baseline (text space (80, -5)). + let a = EyedropperTool.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]); + } + #[test] fn radial_sample_honours_aspect() { let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient {