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();