M3.16: the Gradient tool follows the active proxy and type objects — the tool and annotator edit the fill or the stroke by the focused proxy (ToolContext.fill_active); paint.setGradientGeom takes stroke? (default: the active proxy) and index? (one appearance item), sets type objects' vectors on their runs in text space and keeps the aspect the document shows; paint.setFill/setStroke vectors on type move into text space; aspect edits place run gradients on the layout bounds; run strokes (and type appearance strokes) fit the stroke-inflated box on screen and in PDF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
81f86a9acc
commit
aa1b592935
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<Option<f64>> {
|
|||
|
||||
/// `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<vectorcraft_geom::Rect>) -> Paint {
|
||||
pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> 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<vectorcraft_g
|
|||
}
|
||||
|
||||
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vectorcraft_geom::Rect>) -> std::result::Result<Paint, String> {
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<Rect>) -> std::result::Result<Paint, String> {
|
||||
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<Value> {
|
|||
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<Value> {
|
|||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// 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<Affine> {
|
||||
(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<Rect>) -> Option<Paint> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<Value> {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<f64>() / 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f32>)> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue