M3.98: radial annotator extent ellipse, aspect handle and off-centre focal point
Radial gradients show a dashed extent ellipse: its handle across the bar sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside the centre ring drags the focal point (GradientGeom.focal) for an off-centre radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and start/end may be left out; the focal point keeps its place in the ellipse as the vector, aspect and object transform. It renders as a two-point radial, exports and imports as SVG fx/fy and PDF two-point radial shadings, expands to off-centre rings, and travels in gradient params and the native format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8793eeaef
commit
d8eff17b9f
|
|
@ -236,8 +236,15 @@ pub struct GradientGeom {
|
|||
/// Radial aspect ratio (height / width), 1 = circle.
|
||||
#[serde(default = "one64")]
|
||||
pub aspect: f64,
|
||||
/// A radial gradient's focal point, where its first stop sits (an off-centre radial), in the
|
||||
/// same space; inside the extent ellipse. None: the centre (`start`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focal: Option<Point>,
|
||||
}
|
||||
|
||||
/// How far out a focal point may sit, as a fraction of the extent ellipse.
|
||||
const FOCAL_MAX: f64 = 0.99;
|
||||
|
||||
fn one64() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
|
@ -250,11 +257,11 @@ impl GradientGeom {
|
|||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
match kind {
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 },
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
|
||||
_ => {
|
||||
// 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 }
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0, focal: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -266,6 +273,8 @@ impl GradientGeom {
|
|||
/// (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) {
|
||||
// The focal point is a point like any other.
|
||||
self.focal = self.focal.map(|f| a * f);
|
||||
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);
|
||||
|
|
@ -312,21 +321,76 @@ impl GradientGeom {
|
|||
pub fn rebase(&mut self, from: Rect, to: Rect) {
|
||||
self.start = rebase_point(self.start, from, to);
|
||||
self.end = rebase_point(self.end, from, to);
|
||||
self.focal = self.focal.map(|f| rebase_point(f, from, to));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial,
|
||||
/// measured from the focal point (each level is the ellipse scaled about the line from the
|
||||
/// focal point to the centre).
|
||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||
let u = self.end - self.start;
|
||||
let l2 = u.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
if u.hypot2() < 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,
|
||||
if kind != GradientKind::Radial {
|
||||
return (p - self.start).dot(u) / u.hypot2();
|
||||
}
|
||||
let Some(to_unit) = self.unit_frame().map(|m| m.inverse()) else { return 0.0 };
|
||||
let q = (to_unit * p).to_vec2();
|
||||
let Some(f) = self.focal.map(|f| (to_unit * f).to_vec2()) else { return q.hypot() };
|
||||
// The level t through q: |q - f(1 - t)| = t, the root at t >= 0 (|f| < 1).
|
||||
let d = q - f;
|
||||
let a = f.hypot2() - 1.0;
|
||||
if a.abs() < 1e-12 {
|
||||
return q.hypot();
|
||||
}
|
||||
let b = d.dot(f);
|
||||
(-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a
|
||||
}
|
||||
|
||||
/// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to
|
||||
/// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse).
|
||||
pub fn unit_frame(&self) -> Option<Affine> {
|
||||
let u = self.end - self.start;
|
||||
(u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y]))
|
||||
}
|
||||
|
||||
/// The map that squashes a radial gradient's circle (centre `start`, radius the vector's
|
||||
/// length) into its extent ellipse: by `aspect` across the vector, about the centre.
|
||||
pub fn radial_squash(&self) -> Affine {
|
||||
let angle = (self.end - self.start).atan2();
|
||||
let c = self.start.to_vec2();
|
||||
Affine::translate(c)
|
||||
* Affine::rotate(angle)
|
||||
* Affine::scale_non_uniform(1.0, self.aspect.max(1e-3))
|
||||
* Affine::rotate(-angle)
|
||||
* Affine::translate(-c)
|
||||
}
|
||||
|
||||
/// Where a radial gradient's first stop sits: its focal point, else its centre.
|
||||
pub fn focal_point(&self) -> Point {
|
||||
self.focal.unwrap_or(self.start)
|
||||
}
|
||||
|
||||
/// Set the focal point, pulled inside the extent ellipse; one on the centre (or a gradient
|
||||
/// with no extent) has none.
|
||||
pub fn set_focal(&mut self, f: Option<Point>) {
|
||||
self.focal = f.and_then(|f| {
|
||||
let m = self.unit_frame()?;
|
||||
let q = (m.inverse() * f).to_vec2();
|
||||
let r = q.hypot();
|
||||
(r > 1e-6).then(|| if r > FOCAL_MAX { m * (q * (FOCAL_MAX / r)).to_point() } else { f })
|
||||
});
|
||||
}
|
||||
|
||||
/// After a change of the vector or aspect from `before`, put the focal point back where it
|
||||
/// was relative to the extent ellipse.
|
||||
pub fn keep_focal_from(&mut self, before: &GradientGeom) {
|
||||
let rel = before.focal.zip(before.unit_frame()).map(|(f, m)| m.inverse() * f);
|
||||
self.focal = None;
|
||||
if let (Some(q), Some(m)) = (rel, self.unit_frame()) {
|
||||
self.set_focal(Some(m * q));
|
||||
}
|
||||
}
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
|
|
@ -481,7 +545,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
|
||||
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 }
|
||||
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -503,12 +567,12 @@ mod tests {
|
|||
|
||||
#[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 };
|
||||
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None };
|
||||
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 };
|
||||
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None };
|
||||
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:?}");
|
||||
}
|
||||
|
|
@ -517,7 +581,7 @@ mod tests {
|
|||
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 g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None };
|
||||
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)] {
|
||||
|
|
@ -532,7 +596,7 @@ mod tests {
|
|||
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 };
|
||||
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None };
|
||||
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);
|
||||
|
|
@ -611,11 +675,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn rebase_keeps_relative_positions() {
|
||||
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 };
|
||||
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None };
|
||||
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
|
||||
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
|
||||
// A box without height (a horizontal line) keeps the offset from its centre line.
|
||||
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 };
|
||||
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None };
|
||||
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
|
||||
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
|
||||
// Unplaced paints have nothing to move.
|
||||
|
|
@ -634,4 +698,68 @@ mod tests {
|
|||
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
}
|
||||
|
||||
/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre.
|
||||
fn off_centre() -> GradientGeom {
|
||||
let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None };
|
||||
g.set_focal(Some(Point::new(160.0, 200.0)));
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_round_trips_through_serde_and_defaults_to_none() {
|
||||
let g = off_centre();
|
||||
let s = serde_json::to_string(&g).unwrap();
|
||||
assert!(s.contains("\"focal\""), "{s}");
|
||||
assert_eq!(serde_json::from_str::<GradientGeom>(&s).unwrap(), g);
|
||||
// Placements saved before focal points existed load centred, and centred ones write none.
|
||||
let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap();
|
||||
assert_eq!((old.focal, old.aspect), (None, 1.0));
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("focal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_follows_transforms_and_rebases() {
|
||||
let mut g = off_centre();
|
||||
let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
let f = a * g.focal.unwrap();
|
||||
g.transform(a, GradientKind::Radial);
|
||||
assert!(close(g.focal.unwrap(), f), "{g:?}");
|
||||
// Still at the same parameter: the first stop.
|
||||
assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9);
|
||||
let mut h = off_centre();
|
||||
h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_runs_from_the_focal_point_to_the_ellipse() {
|
||||
let g = off_centre();
|
||||
let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y));
|
||||
assert!(t(160.0, 200.0).abs() < 1e-9);
|
||||
// The extent circle is the last stop all round.
|
||||
for a in [0.0f64, 1.0, 2.5, 4.0] {
|
||||
let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin());
|
||||
assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}");
|
||||
}
|
||||
// The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point
|
||||
// (0.4 of the radius off), with a radius of 2/7.
|
||||
assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0));
|
||||
assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier");
|
||||
assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() {
|
||||
let mut g = off_centre();
|
||||
g.set_focal(Some(Point::new(500.0, 200.0)));
|
||||
assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}");
|
||||
g.set_focal(Some(Point::new(200.0, 200.0)));
|
||||
assert_eq!(g.focal, None, "on the centre: centred");
|
||||
// A new vector and aspect keep it in its place in the ellipse.
|
||||
let before = off_centre();
|
||||
let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None };
|
||||
h.keep_focal_from(&before);
|
||||
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,6 +779,7 @@ impl Appearance {
|
|||
&& ga.start.distance(go.start) <= 1e-6
|
||||
&& ga.end.distance(go.end) <= 1e-6
|
||||
&& (ga.aspect - go.aspect).abs() <= 1e-6
|
||||
&& ga.focal_point().distance(go.focal_point()) <= 1e-6
|
||||
{
|
||||
*ga = *go;
|
||||
}
|
||||
|
|
@ -971,7 +972,7 @@ mod tests {
|
|||
use vectorcraft_geom::{Point, Rect};
|
||||
let placed = |x: f64| {
|
||||
let mut g = GradientPaint::new(Gradient::default());
|
||||
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 });
|
||||
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(g))
|
||||
};
|
||||
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
|
|||
g.start = ga.start.lerp(gb.start, t as f64);
|
||||
g.end = ga.end.lerp(gb.end, t as f64);
|
||||
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
|
||||
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
|
||||
out.geom = Some(g);
|
||||
} else {
|
||||
out.geom = None;
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ fn linear(angle: f64, stops: &[(f32, &str, f32)]) -> Paint {
|
|||
/// A radial gradient placed in the unit box (each object gets it at the same place relative to its
|
||||
/// bounds): centred at `(x, y)` with `radius`.
|
||||
fn radial_at(x: f64, y: f64, radius: f64, stops: &[(f32, &str, f32)]) -> Paint {
|
||||
let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0 };
|
||||
let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0, focal: None };
|
||||
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(gradient(GradientKind::Radial, stops)) }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
|
||||
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
|
||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 });
|
||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(g))
|
||||
};
|
||||
let circle = |centre: Point, radius: f64| shapes::ellipse(Rect::from_center_size(centre, (radius * 2.0, radius * 2.0)));
|
||||
|
|
|
|||
|
|
@ -314,10 +314,11 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32
|
|||
/// A linear or radial gradient's geometry over an object's box: its start, the vector to its
|
||||
/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that
|
||||
/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial:
|
||||
/// `0..t1`).
|
||||
/// `0..t1`, each level's ellipse centred on the way from the focal point to the start).
|
||||
struct Frame {
|
||||
radial: bool,
|
||||
start: Point,
|
||||
focal: Point,
|
||||
along: Vec2,
|
||||
across: Vec2,
|
||||
t0: f64,
|
||||
|
|
@ -344,13 +345,17 @@ impl Frame {
|
|||
}
|
||||
// Keep a sliver of span so the art maps back invertibly.
|
||||
t1 = t1.max(t0 + 1e-6);
|
||||
Self { radial, start: geom.start, along, across, t0, t1, s0, s1 }
|
||||
Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
|
||||
}
|
||||
|
||||
/// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t`
|
||||
/// and angle `s` (radians).
|
||||
fn at(&self, t: f64, s: f64) -> Point {
|
||||
if self.radial { self.start + (self.along * s.cos() + self.across * s.sin()) * t } else { self.start + self.along * t + self.across * s }
|
||||
if self.radial {
|
||||
self.focal + (self.start - self.focal + self.along * s.cos() + self.across * s.sin()) * t
|
||||
} else {
|
||||
self.start + self.along * t + self.across * s
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour
|
||||
|
|
@ -371,7 +376,7 @@ impl Frame {
|
|||
|
||||
/// `n` solid shapes covering the box in paint order, each coloured as the gradient in the
|
||||
/// middle of its band: for a linear gradient, rectangles from each band's start to the far
|
||||
/// end, for a radial one, concentric ellipses (largest first). Each paints over the ones
|
||||
/// end, for a radial one, nested ellipses (largest first). Each paints over the ones
|
||||
/// below except its band, so no seam shows between bands. With translucent stops each shape
|
||||
/// is just its band, so nothing doubles up.
|
||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||
|
|
@ -380,7 +385,8 @@ impl Frame {
|
|||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if self.radial {
|
||||
let ellipse = |t: f64| {
|
||||
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, self.start.x, self.start.y]);
|
||||
let c = self.focal + (self.start - self.focal) * t;
|
||||
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]);
|
||||
shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m)
|
||||
};
|
||||
return (0..n)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} 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)",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
|
|
@ -181,16 +181,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
|||
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()),
|
||||
let geom = GeomEdit::parse(g)?;
|
||||
if let Some((start, end)) = geom.vector {
|
||||
let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None };
|
||||
g.set_focal(geom.focal.flatten());
|
||||
gp.angle = g.angle_deg();
|
||||
gp.geom = Some(g);
|
||||
}
|
||||
if let Some(f) = g.get("freeform") {
|
||||
gp.freeform = Some(super::freeform::parse_freeform(f)?);
|
||||
|
|
@ -282,6 +278,8 @@ pub(crate) fn apply_gradient_edit_in(
|
|||
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
|
||||
}
|
||||
}
|
||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||
let before = gp.geom;
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
gp.angle = a;
|
||||
|
|
@ -308,6 +306,9 @@ pub(crate) fn apply_gradient_edit_in(
|
|||
g.aspect = asp;
|
||||
}
|
||||
}
|
||||
if let (Some(b), Some(g)) = (before, &mut gp.geom) {
|
||||
g.keep_focal_from(&b);
|
||||
}
|
||||
if gp.gradient.kind == GradientKind::Freeform {
|
||||
// Points already shown (automatic ones fitted to the box) stay put; a fresh freeform
|
||||
// gradient gets its points inside the shape.
|
||||
|
|
@ -428,10 +429,35 @@ 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> {
|
||||
/// What `paint.setGradientGeom` sets, in document coordinates (None: kept).
|
||||
struct GeomEdit {
|
||||
vector: Option<(Point, Point)>,
|
||||
aspect: Option<f64>,
|
||||
/// Some(None) centres the focal point.
|
||||
focal: Option<Option<Point>>,
|
||||
}
|
||||
|
||||
impl GeomEdit {
|
||||
fn parse(p: &Value) -> std::result::Result<Self, String> {
|
||||
let point = |k: &str| p.get(k).map(|_| point_param(p, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose();
|
||||
let vector = match (point("start")?, point("end")?) {
|
||||
(Some(s), Some(e)) => Some((s, e)),
|
||||
(None, None) => None,
|
||||
_ => return Err("give both `start` and `end` [x, y] (or neither)".into()),
|
||||
};
|
||||
let focal = match p.get("focal") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(_) => Some(point("focal")?),
|
||||
};
|
||||
Ok(Self { vector, aspect: aspect_param(p)?, focal })
|
||||
}
|
||||
}
|
||||
|
||||
/// `cur` with the vector, aspect and focal point of `e` (given 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 what the document shows carries over where `e` keeps it.
|
||||
fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option<Rect>) -> Option<Paint> {
|
||||
let from_doc = invert(to_doc)?;
|
||||
let mut gp = match cur {
|
||||
Paint::Gradient(g) => (**g).clone(),
|
||||
|
|
@ -444,8 +470,17 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O
|
|||
}
|
||||
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);
|
||||
let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc));
|
||||
let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?;
|
||||
let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
|
||||
let mut geom = GradientGeom { start, end, aspect, focal: None };
|
||||
match (e.focal, shown) {
|
||||
_ if kind != GradientKind::Radial => {}
|
||||
(Some(f), _) => geom.set_focal(f),
|
||||
(None, Some(b)) => geom.keep_focal_from(&b),
|
||||
(None, None) => {}
|
||||
}
|
||||
let geom = map(geom, from_doc);
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
Some(Paint::Gradient(Box::new(gp)))
|
||||
|
|
@ -453,8 +488,7 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O
|
|||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 edit = GeomEdit::parse(p).map_err(|e| bad(C, e))?;
|
||||
// `index` is an alias of `item`.
|
||||
let mut p = p.clone();
|
||||
if let (None, Some(i)) = (p.get("item"), p.get("index").cloned()) {
|
||||
|
|
@ -481,14 +515,14 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
run_stroke_weight(&mut r.style);
|
||||
}
|
||||
let (paint, b) = run_paint_mut(r, stroke, lb);
|
||||
if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) {
|
||||
if let Some(np) = vector_paint(paint, &edit, xf, Some(b)) {
|
||||
*paint = np;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let b = item_paint_bounds(n, index, !stroke);
|
||||
if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), start, end, Affine::IDENTITY, b) {
|
||||
if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b) {
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -875,6 +875,8 @@ mod tests_flatpreview;
|
|||
#[cfg(test)]
|
||||
mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
//! Radial gradients' extent and focal point (M3.98): `paint.setGradientGeom {aspect, focal}`,
|
||||
//! focal points through transforms, params and the native format, and the radial annotator.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{GradientGeom, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 200 pt square at (100, 100) with a radial fill centred on it, radius 100.
|
||||
fn radial() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 200], "end": [300, 200]}})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn geom(s: &Session, id: NodeId) -> GradientGeom {
|
||||
match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.expect("placed"),
|
||||
p => panic!("not a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-6
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_gradient_geom_sets_aspect_and_focal_in_one_step() {
|
||||
let (mut s, id) = radial();
|
||||
let n = undo_len(&s);
|
||||
s.execute("paint.setGradientGeom", &json!({"focal": [160, 210], "aspect": 50})).unwrap();
|
||||
let g = geom(&s, id);
|
||||
assert_eq!((g.start, g.end, g.aspect, g.focal), (Point::new(200.0, 200.0), Point::new(300.0, 200.0), 0.5, Some(Point::new(160.0, 210.0))));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// Out of the extent ellipse it is pulled in; null centres it.
|
||||
s.execute("paint.setGradientGeom", &json!({"focal": [200, 400]})).unwrap();
|
||||
assert!(close(geom(&s, id).focal.unwrap(), Point::new(200.0, 249.5)), "{:?}", geom(&s, id));
|
||||
s.execute("paint.setGradientGeom", &json!({"focal": null})).unwrap();
|
||||
assert_eq!(geom(&s, id).focal, None);
|
||||
// One end without the other is an error; linear gradients have no focal point.
|
||||
assert!(s.execute("paint.setGradientGeom", &json!({"end": [0, 0]})).is_err());
|
||||
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"start": [100, 200], "end": [300, 200], "focal": [150, 200]})).unwrap();
|
||||
assert_eq!(geom(&s, id).focal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focal_point_keeps_its_place_as_the_gradient_moves_and_reshapes() {
|
||||
let (mut s, id) = radial();
|
||||
s.execute("paint.setGradientGeom", &json!({"focal": [160, 210]})).unwrap();
|
||||
// A new vector: moved 10 right and turned to point down.
|
||||
s.execute("paint.setGradientGeom", &json!({"start": [210, 200], "end": [210, 300]})).unwrap();
|
||||
assert!(close(geom(&s, id).focal.unwrap(), Point::new(200.0, 160.0)), "{:?}", geom(&s, id));
|
||||
// The aspect ratio (Gradient panel) squashes it with the ellipse.
|
||||
s.execute("paint.editGradient", &json!({"aspect": 50})).unwrap();
|
||||
assert!(close(geom(&s, id).focal.unwrap(), Point::new(205.0, 160.0)), "{:?}", geom(&s, id));
|
||||
// Transforming the object maps it.
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 7})).unwrap();
|
||||
assert!(close(geom(&s, id).focal.unwrap(), Point::new(210.0, 167.0)), "{:?}", geom(&s, id));
|
||||
s.execute("object.transform", &json!({"matrix": [0, 1, -1, 0, 0, 0]})).unwrap();
|
||||
assert!(close(geom(&s, id).focal.unwrap(), Point::new(-167.0, 210.0)), "{:?}", geom(&s, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focal_point_round_trips_through_params_and_the_native_format() {
|
||||
let (mut s, id) = radial();
|
||||
s.execute("paint.setGradientGeom", &json!({"focal": [170, 190], "aspect": 80})).unwrap();
|
||||
let want = geom(&s, id);
|
||||
// Gradient params (what swatches and agents read) carry it, and give it back.
|
||||
let Paint::Gradient(g) = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() else { unreachable!() };
|
||||
let params = vectorcraft_tools::params::gradient_params(&g);
|
||||
assert_eq!(params["focal"], json!([170.0, 190.0]));
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||||
assert_eq!(geom(&s, id), want);
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("gradient lost") };
|
||||
assert_eq!(g.geom, Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_annotator_drags_the_focal_dot_the_aspect_handle_and_the_ring() {
|
||||
let (mut s, id) = radial();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("gradient", v).unwrap();
|
||||
let drag = |s: &mut Session, pts: &[(f64, f64)]| {
|
||||
let n = undo_len(s);
|
||||
for (i, (x, y)) in pts.iter().enumerate() {
|
||||
let kind = [PointerKind::Down, PointerKind::Drag][i.min(1)];
|
||||
s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
let (x, y) = pts[pts.len() - 1];
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
|
||||
assert_eq!(undo_len(s), n + 1, "one undo step");
|
||||
};
|
||||
// The focal dot sits on the centre: dragging it moves only the focal point.
|
||||
drag(&mut s, &[(200.0, 200.0), (180.0, 195.0), (170.0, 190.0)]);
|
||||
let g = geom(&s, id);
|
||||
assert_eq!((g.start, g.focal), (Point::new(200.0, 200.0), Some(Point::new(170.0, 190.0))));
|
||||
// Back onto the centre, it centres.
|
||||
drag(&mut s, &[(170.0, 190.0), (190.0, 195.0), (200.5, 200.5)]);
|
||||
assert_eq!(geom(&s, id).focal, None);
|
||||
// The handle across the bar (100 pt above the centre) sets the aspect ratio.
|
||||
drag(&mut s, &[(200.0, 100.0), (200.0, 120.0), (200.0, 150.0)]);
|
||||
assert!((geom(&s, id).aspect - 0.5).abs() < 1e-9, "{:?}", geom(&s, id));
|
||||
// The ellipse elsewhere rotates it, keeping its radius: from its bottom round to its left
|
||||
// turns the vector a quarter clockwise, to point down.
|
||||
drag(&mut s, &[(200.0, 250.0), (170.0, 240.0), (150.0, 200.0)]);
|
||||
let g = geom(&s, id);
|
||||
assert!(close(g.end, Point::new(200.0, 300.0)) && (g.aspect - 0.5).abs() < 1e-9, "{g:?}");
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ fn three_stops() -> GradientPaint {
|
|||
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.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5, focal: None });
|
||||
g.angle = g.geom.unwrap().angle_deg();
|
||||
g
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,17 +458,14 @@ impl Exporter<'_> {
|
|||
}
|
||||
GradientKind::Radial => {
|
||||
let r = geom.length().max(1e-6) as f32;
|
||||
let angle = (geom.end - geom.start).atan2();
|
||||
let t = Affine::translate(geom.start.to_vec2())
|
||||
* Affine::rotate(angle)
|
||||
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
|
||||
* Affine::rotate(-angle)
|
||||
* Affine::translate(-geom.start.to_vec2());
|
||||
let t = geom.radial_squash();
|
||||
let (cx, cy) = (geom.start.x as f32, geom.start.y as f32);
|
||||
// An off-centre focal point: a two-point radial shading from it.
|
||||
let f = t.inverse() * geom.focal_point();
|
||||
Some(
|
||||
RadialGradient {
|
||||
fx: cx,
|
||||
fy: cy,
|
||||
fx: f.x as f32,
|
||||
fy: f.y as f32,
|
||||
fr: 0.0,
|
||||
cx,
|
||||
cy,
|
||||
|
|
|
|||
|
|
@ -401,16 +401,17 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
|
|||
};
|
||||
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
||||
let (kind, geom, stops) = if *axial {
|
||||
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0 };
|
||||
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
||||
(GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::<Vec<_>>())
|
||||
} else {
|
||||
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
|
||||
let centre = c(coords[3], coords[4]);
|
||||
let k = m.as_coeffs();
|
||||
let ex = Vec2::new(k[0], k[1]) * r1 as f64;
|
||||
let ey = Vec2::new(k[2], k[3]) * r1 as f64;
|
||||
let aspect = if ex.hypot() > 1e-9 { ey.hypot() / ex.hypot() } else { 1.0 };
|
||||
let geom = GradientGeom { start: centre, end: centre + ex, aspect };
|
||||
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
|
||||
// gives the focal point.
|
||||
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
|
||||
let centre = p(coords[3], coords[4]);
|
||||
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
|
||||
geom.set_focal(Some(p(coords[0], coords[1])));
|
||||
geom.transform(m, GradientKind::Radial);
|
||||
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
|
||||
let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect();
|
||||
(GradientKind::Radial, geom, stops)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ mod tests_cmykblend;
|
|||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
//! Radial gradients' focal points as two-point radial shadings, out and back in.
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A page-sized square filled with a radial on `start`→`end` at `aspect`, focal point `focal`.
|
||||
fn doc(start: Point, end: Point, aspect: f64, focal: Point) -> Document {
|
||||
let mut geom = GradientGeom { start, end, aspect, focal: None };
|
||||
geom.set_focal(Some(focal));
|
||||
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
|
||||
gp.geom = Some(geom);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn geom(d: &Document) -> GradientGeom {
|
||||
let mut out = None;
|
||||
d.walk(|n| {
|
||||
if let Paint::Gradient(g) = n.appearance.fill_paint() {
|
||||
out = g.geom;
|
||||
}
|
||||
});
|
||||
out.expect("a gradient fill")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_focal_point_round_trips_as_a_two_point_radial_shading() {
|
||||
for (end, aspect) in [(Point::new(180.0, 100.0), 1.0), (Point::new(100.0, 40.0), 0.5)] {
|
||||
let d = doc(Point::new(100.0, 100.0), end, aspect, Point::new(80.0, 110.0));
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let g = geom(&back);
|
||||
let f = g.focal.expect("focal kept");
|
||||
assert!(f.distance(Point::new(80.0, 110.0)) < 0.05 && g.start.distance(Point::new(100.0, 100.0)) < 0.05, "{g:?}");
|
||||
// The same ellipse (its axes may come back swapped, with the aspect inverted).
|
||||
let want = geom(&d);
|
||||
for p in [Point::new(30.0, 100.0), Point::new(100.0, 170.0), Point::new(140.0, 60.0), Point::new(90.0, 125.0)] {
|
||||
let (a, b) = (g.param_at(GradientKind::Radial, p), want.param_at(GradientKind::Radial, p));
|
||||
assert!((a - b).abs() < 1e-3, "{p:?}: {a} vs {b}: {g:?}");
|
||||
}
|
||||
// And it looks the same.
|
||||
assert_similar(&render_artboard(&back), &render_artboard(&d), 3.0, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_radial_imports_without_a_focal_point() {
|
||||
let d = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(100.0, 100.0));
|
||||
assert_eq!(geom(&import(&export(&d, &PdfOptions::default()).unwrap()).unwrap()).focal, None);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! Paint conversion (solid, gradients, patterns) and image decoding.
|
||||
|
||||
use vectorcraft_color::{GradientKind, Paint};
|
||||
use vectorcraft_geom::{Affine, Rect, Vec2};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko::{self, ColorStop};
|
||||
|
||||
|
|
@ -26,16 +26,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
|||
let grad = match g.gradient.kind {
|
||||
GradientKind::Radial => {
|
||||
let r = geom.length().max(1e-6) as f32;
|
||||
let angle = (geom.end - geom.start).atan2();
|
||||
// Aspect ratio squashes the circle along the perpendicular of the gradient vector.
|
||||
ctx.set_paint_transform(
|
||||
Affine::translate(geom.start.to_vec2())
|
||||
* Affine::rotate(angle)
|
||||
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
|
||||
* Affine::rotate(-angle)
|
||||
* Affine::translate(-geom.start.to_vec2()),
|
||||
);
|
||||
peniko::Gradient::new_radial(geom.start, r).with_stops(stops.as_slice())
|
||||
// The aspect ratio squashes the circle across the gradient vector.
|
||||
let squash = geom.radial_squash();
|
||||
ctx.set_paint_transform(squash);
|
||||
let g = match geom.focal {
|
||||
// An off-centre focal point: a two-point radial from it to the extent circle.
|
||||
Some(f) => peniko::Gradient::new_two_point_radial(squash.inverse() * f, 0.0, geom.start, r),
|
||||
None => peniko::Gradient::new_radial(geom.start, r),
|
||||
};
|
||||
g.with_stops(stops.as_slice())
|
||||
}
|
||||
_ => {
|
||||
ctx.reset_paint_transform();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
//! Off-centre (focal) radial gradients: the pixels follow the model's gradient parameter, with
|
||||
//! and without an aspect ratio, and differ from the centred gradient.
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
use vectorcraft_testkit::raster::{Image, render_view};
|
||||
|
||||
/// A 100 pt square filled with a white-to-black radial on `geom`.
|
||||
fn radial(geom: GradientGeom) -> Document {
|
||||
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
|
||||
gp.geom = Some(geom);
|
||||
let mut b = DocBuilder::new(100.0, 100.0);
|
||||
b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0), |_| {});
|
||||
b.build()
|
||||
}
|
||||
|
||||
/// A circle of radius 40 at (50, 50) with its focal point at `focal`.
|
||||
fn off_centre(focal: Option<Point>) -> GradientGeom {
|
||||
let mut g = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(90.0, 50.0), aspect: 1.0, focal: None };
|
||||
g.set_focal(focal);
|
||||
g
|
||||
}
|
||||
|
||||
/// Every 7th pixel's red matches white→black at the model's parameter there (pixel centres).
|
||||
fn assert_follows_model(img: &Image, geom: &GradientGeom) {
|
||||
for y in (3..100).step_by(7) {
|
||||
for x in (3..100).step_by(7) {
|
||||
let t = geom.param_at(GradientKind::Radial, Point::new(x as f64 + 0.5, y as f64 + 0.5)).clamp(0.0, 1.0);
|
||||
let want = ((1.0 - t) * 255.0).round() as u8;
|
||||
let got = img.over_white(x, y)[0];
|
||||
assert!(got.abs_diff(want) <= 6, "({x},{y}) t={t:.3}: got {got}, want {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_centre_focal_point_renders_as_its_model() {
|
||||
let geom = off_centre(Some(Point::new(30.0, 40.0)));
|
||||
let img = render_view(&radial(geom), 100, 100);
|
||||
assert_follows_model(&img, &geom);
|
||||
// White at the focal point, and the light side faces it.
|
||||
assert!(img.over_white(30, 40)[0] > 245, "{:?}", img.over_white(30, 40));
|
||||
let centred = render_view(&radial(off_centre(None)), 100, 100);
|
||||
assert!(img.over_white(35, 50)[0] > centred.over_white(35, 50)[0] + 20);
|
||||
assert!(img.over_white(65, 50)[0] + 20 < centred.over_white(65, 50)[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_focal_point_on_a_squashed_rotated_ellipse_renders_as_its_model() {
|
||||
let mut geom = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(80.0, 75.0), aspect: 0.5, focal: None };
|
||||
geom.set_focal(Some(Point::new(60.0, 50.0)));
|
||||
assert!(geom.focal.is_some());
|
||||
assert_follows_model(&render_view(&radial(geom), 100, 100), &geom);
|
||||
}
|
||||
|
|
@ -377,14 +377,17 @@ impl Writer<'_> {
|
|||
GradientKind::Radial => {
|
||||
let id = self.fresh_id("radial-gradient");
|
||||
let r = (e - s).hypot();
|
||||
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
|
||||
let focal = |f: Point| format!(" fx=\"{}\" fy=\"{}\"", self.num(f.x), self.num(f.y));
|
||||
if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
(
|
||||
id.clone(),
|
||||
format!(
|
||||
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\" gradientUnits=\"userSpaceOnUse\">",
|
||||
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\"{} gradientUnits=\"userSpaceOnUse\">",
|
||||
self.num(s.x),
|
||||
self.num(s.y),
|
||||
self.num(r)
|
||||
self.num(r),
|
||||
geom.focal.map(focal).unwrap_or_default()
|
||||
),
|
||||
"radialGradient",
|
||||
)
|
||||
|
|
@ -394,8 +397,9 @@ impl Writer<'_> {
|
|||
(
|
||||
id.clone(),
|
||||
format!(
|
||||
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\" gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
|
||||
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\"{} gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
|
||||
self.num(r),
|
||||
geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(),
|
||||
self.matrix(m)
|
||||
),
|
||||
"radialGradient",
|
||||
|
|
|
|||
|
|
@ -406,8 +406,12 @@ impl Importer {
|
|||
if lg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id()));
|
||||
}
|
||||
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 };
|
||||
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,
|
||||
focal: None,
|
||||
};
|
||||
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
|
||||
gp(GradientKind::Linear, stops(lg), geom)
|
||||
}
|
||||
|
|
@ -415,11 +419,13 @@ impl Importer {
|
|||
if rg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id()));
|
||||
}
|
||||
if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 {
|
||||
self.warn(format!("gradient '{}': focal point ignored", rg.id()));
|
||||
if rg.fr().get() > 1e-4 {
|
||||
self.warn(format!("gradient '{}': focal radius ignored", rg.id()));
|
||||
}
|
||||
let c = Point::new(rg.cx() as f64, rg.cy() as f64);
|
||||
let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 };
|
||||
let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None };
|
||||
// A focal point outside the circle is pulled inside it.
|
||||
geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64)));
|
||||
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
|
||||
gp(GradientKind::Radial, stops(rg), geom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
//! Radial gradients' focal points as `fx`/`fy`, out and back in.
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export, import};
|
||||
|
||||
/// One square filled with a radial on `start`→`end` at `aspect`, its focal point at `focal`.
|
||||
fn doc(start: Point, end: Point, aspect: f64, focal: Point) -> Document {
|
||||
let mut geom = GradientGeom { start, end, aspect, focal: None };
|
||||
geom.set_focal(Some(focal));
|
||||
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
|
||||
gp.geom = Some(geom);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn geom(d: &Document) -> GradientGeom {
|
||||
let mut out = None;
|
||||
d.walk(|n| {
|
||||
if let Paint::Gradient(g) = n.appearance.fill_paint() {
|
||||
out = g.geom;
|
||||
}
|
||||
});
|
||||
out.expect("a gradient fill")
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_writes_its_focal_point_as_fx_fy_and_reads_it_back() {
|
||||
let d = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(70.0, 110.0));
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("cx=\"100\" cy=\"100\" r=\"80\" fx=\"70\" fy=\"110\""), "{svg}");
|
||||
let g = geom(&import(&svg).unwrap());
|
||||
assert!(close(g.start, Point::new(100.0, 100.0)) && close(g.focal.expect("focal kept"), Point::new(70.0, 110.0)), "{g:?}");
|
||||
// A centred radial writes none.
|
||||
let centred = doc(Point::new(100.0, 100.0), Point::new(180.0, 100.0), 1.0, Point::new(100.0, 100.0));
|
||||
let svg = export(¢red, &ExportOptions::default());
|
||||
assert!(!svg.contains("fx="), "{svg}");
|
||||
assert_eq!(geom(&import(&svg).unwrap()).focal, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ellipse_writes_its_focal_point_in_the_gradient_space() {
|
||||
let d = doc(Point::new(100.0, 100.0), Point::new(100.0, 40.0), 0.5, Point::new(110.0, 120.0));
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("fx=") && svg.contains("gradientTransform"), "{svg}");
|
||||
let g = geom(&import(&svg).unwrap());
|
||||
assert!(close(g.focal.expect("focal kept"), Point::new(110.0, 120.0)), "{g:?}");
|
||||
assert!((g.aspect - 0.5).abs() < 1e-3, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_focal_points_outside_the_circle_are_pulled_in() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<radialGradient id="g" cx="100" cy="100" r="50" fx="0" fy="100" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></radialGradient>
|
||||
<rect width="200" height="200" fill="url(#g)"/></svg>"##;
|
||||
let g = geom(&import(svg).unwrap());
|
||||
let f = g.focal.expect("focal kept");
|
||||
assert!(f.y == 100.0 && f.x > 50.0 && f.x < 51.0, "{g:?}");
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ fn linear_gradient_roundtrip() {
|
|||
g.stops[1].opacity = 0.5;
|
||||
let gp = GradientPaint {
|
||||
gradient: g,
|
||||
geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0 }),
|
||||
geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
freeform: None,
|
||||
|
|
@ -258,7 +258,7 @@ fn radial_gradient_and_fit_roundtrip() {
|
|||
// Elliptical radial via gradientTransform.
|
||||
let gp = GradientPaint {
|
||||
gradient: g,
|
||||
geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5 }),
|
||||
geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
freeform: None,
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ pub fn freeform_json(f: &Freeform) -> Value {
|
|||
}
|
||||
|
||||
/// 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 (%)
|
||||
/// and the freeform points.
|
||||
/// opacity and midpoint), angle, the linked swatch and, once placed, the vector, aspect (%) and
|
||||
/// focal point, and the freeform points.
|
||||
pub fn gradient_params(g: &GradientPaint) -> Value {
|
||||
let mut v = json!({
|
||||
"kind": g.gradient.kind.label().to_lowercase(),
|
||||
|
|
@ -59,6 +59,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value {
|
|||
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(f) = geom.focal {
|
||||
v["focal"] = json!([f.x, f.y]);
|
||||
}
|
||||
}
|
||||
if let Some(s) = &g.swatch {
|
||||
v["swatch"] = json!(s);
|
||||
|
|
@ -82,7 +85,7 @@ mod tests {
|
|||
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.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5, focal: None });
|
||||
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)));
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ mod tests {
|
|||
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 });
|
||||
g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0, focal: None });
|
||||
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());
|
||||
|
|
@ -246,7 +246,7 @@ mod tests {
|
|||
..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 });
|
||||
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5, focal: None });
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! A radial gradient also shows its extent: a dashed ellipse around the centre (the start, a
|
||||
//! ring). Drag the dot on the ellipse across the bar to change the aspect ratio and the ellipse
|
||||
//! elsewhere to rotate it; the end handle sets the radius. The dot inside the centre ring is the
|
||||
//! focal point (where the first stop sits): drag it to make the gradient off-centre, back onto
|
||||
//! the centre to centre it.
|
||||
//!
|
||||
//! On a freeform gradient the tool edits its points instead (see `freeform`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -21,7 +27,7 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos
|
|||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
|
||||
use vectorcraft_geom::{Affine, Point, Shape, Vec2, constrain_angle_from};
|
||||
|
||||
use super::freeform::{self, Annotator as FreeformAnnotator};
|
||||
use super::paint_owner;
|
||||
|
|
@ -44,6 +50,10 @@ 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 radial's focal dot, and the centre ring around it (the start handle).
|
||||
const FOCAL_HIT: f64 = 3.0;
|
||||
const CENTRE: f64 = 4.5;
|
||||
const CENTRE_HIT: f64 = 8.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.
|
||||
|
|
@ -68,8 +78,12 @@ pub enum Part {
|
|||
Mid(usize),
|
||||
Start,
|
||||
End,
|
||||
/// Just past the end: rotates the vector about the start.
|
||||
/// Just past the end (or a radial's extent ellipse): rotates the vector about the start.
|
||||
Rotate,
|
||||
/// A radial's focal point.
|
||||
Focal,
|
||||
/// The handle on a radial's extent ellipse, across the bar: the aspect ratio.
|
||||
Aspect,
|
||||
/// The bar at an offset (0..1): a click adds a stop there.
|
||||
Bar(f32),
|
||||
}
|
||||
|
|
@ -85,6 +99,20 @@ impl Annotator {
|
|||
fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self {
|
||||
Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } }
|
||||
}
|
||||
fn radial(&self) -> bool {
|
||||
self.gradient.kind == GradientKind::Radial
|
||||
}
|
||||
/// A radial's aspect handle: on the extent ellipse, across the bar from the stops.
|
||||
pub fn aspect_point(&self) -> Point {
|
||||
self.geom.start - self.normal() * (self.vector().hypot() * self.geom.aspect)
|
||||
}
|
||||
/// How far `p` is from a radial's extent ellipse (approximately, along the ray from the
|
||||
/// centre).
|
||||
fn off_ellipse(&self, p: Point) -> Option<f64> {
|
||||
let m = self.geom.unit_frame()?;
|
||||
let rho = (m.inverse() * p).to_vec2().hypot();
|
||||
(rho > 1e-12).then(|| p.distance(self.geom.start) * (1.0 - 1.0 / rho).abs())
|
||||
}
|
||||
fn vector(&self) -> Vec2 {
|
||||
self.geom.end - self.geom.start
|
||||
}
|
||||
|
|
@ -131,9 +159,16 @@ impl Annotator {
|
|||
if near(Some(self.geom.end), HANDLE) {
|
||||
return Some(Part::End);
|
||||
}
|
||||
if near(Some(self.geom.start), HANDLE) {
|
||||
let radial = self.radial();
|
||||
if radial && near(Some(self.geom.focal_point()), FOCAL_HIT) {
|
||||
return Some(Part::Focal);
|
||||
}
|
||||
if near(Some(self.geom.start), if radial { CENTRE_HIT } else { HANDLE }) {
|
||||
return Some(Part::Start);
|
||||
}
|
||||
if radial && near(Some(self.aspect_point()), HANDLE) {
|
||||
return Some(Part::Aspect);
|
||||
}
|
||||
let (t, d) = self.project(p);
|
||||
if t > 1.0 && near(Some(self.geom.end), ROTATE) {
|
||||
return Some(Part::Rotate);
|
||||
|
|
@ -142,6 +177,9 @@ impl Annotator {
|
|||
if self.vector().hypot() > 1e-12 && (0.0..=1.0).contains(&t) && across.contains(&d) {
|
||||
return Some(Part::Bar(t as f32));
|
||||
}
|
||||
if radial && self.off_ellipse(p).is_some_and(|d| d <= cx.tol(BAR_HIT)) {
|
||||
return Some(Part::Rotate);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +195,13 @@ enum Grab {
|
|||
bar: Option<f32>,
|
||||
},
|
||||
End(GradientGeom),
|
||||
Rotate(GradientGeom),
|
||||
/// Rotates the vector, by the pointer's turn about the start from `from` (radians).
|
||||
Rotate {
|
||||
geom: GradientGeom,
|
||||
from: f64,
|
||||
},
|
||||
Focal(GradientGeom),
|
||||
Aspect(GradientGeom),
|
||||
Stop {
|
||||
index: usize,
|
||||
from: Annotator,
|
||||
|
|
@ -194,16 +238,27 @@ pub struct GradientTool {
|
|||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
|
||||
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop; a
|
||||
/// radial's extent ellipse with its aspect handle, centre ring and focal dot.
|
||||
fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec<Overlay> {
|
||||
let (s, e) = (a.geom.start, a.geom.end);
|
||||
let shade = Vec2::new(0.0, cx.tol(1.0));
|
||||
let mut o = vec![
|
||||
let mut o = vec![];
|
||||
if let Some(m) = a.geom.unit_frame().filter(|_| a.radial()) {
|
||||
// Dark dashes over a light line: readable on any art.
|
||||
let path = vectorcraft_geom::kurbo::Ellipse::from_affine(m).to_path(cx.tol(0.25));
|
||||
o.push(Overlay::Path { path: path.clone(), color: LIGHT, width: 1.0, dashed: false });
|
||||
o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: true });
|
||||
o.push(Overlay::Handle { p: a.aspect_point(), color: BAR });
|
||||
let ring = vectorcraft_geom::kurbo::Circle::new(s, cx.tol(CENTRE)).to_path(cx.tol(0.25));
|
||||
o.push(Overlay::Path { path: ring, color: BAR, width: 1.5, dashed: false });
|
||||
}
|
||||
o.extend([
|
||||
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::Handle { p: if a.radial() { a.geom.focal_point() } else { s }, color: BAR },
|
||||
Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 },
|
||||
];
|
||||
]);
|
||||
let len = a.vector().hypot();
|
||||
if len < 1e-12 {
|
||||
return o;
|
||||
|
|
@ -262,7 +317,9 @@ impl GradientTool {
|
|||
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),
|
||||
Some((a, Part::Rotate)) => Grab::Rotate { geom: a.geom, from: (p - a.geom.start).atan2() - a.vector().atan2() },
|
||||
Some((a, Part::Focal)) => Grab::Focal(a.geom),
|
||||
Some((a, Part::Aspect)) => Grab::Aspect(a.geom),
|
||||
None => {
|
||||
if cx.selection.is_empty() {
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
|
||||
|
|
@ -312,12 +369,27 @@ impl GradientTool {
|
|||
("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start)))
|
||||
}
|
||||
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))),
|
||||
Grab::Rotate(geom) => {
|
||||
let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start };
|
||||
Grab::Rotate { geom, from } => {
|
||||
let dir = (Affine::rotate(-from) * (p - geom.start).to_point()).to_vec2();
|
||||
let dir = if mods.shift { constrain(dir) } else { dir };
|
||||
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::Focal(geom) => {
|
||||
// Dropped back on the centre it centres.
|
||||
let f = geom.focal_point() + (p - g.at);
|
||||
let mut params = Self::geom_params(cx, geom.start, geom.end);
|
||||
params["focal"] = if f.distance(geom.start) <= cx.tol(FOCAL_HIT) { Value::Null } else { json!([f.x, f.y]) };
|
||||
("paint.setGradientGeom", params)
|
||||
}
|
||||
Grab::Aspect(geom) => {
|
||||
// The pointer's distance across the bar, as a fraction of the radius.
|
||||
let across = Annotator::with(*geom, &[]).project(p).1.abs();
|
||||
let mut params = Self::geom_params(cx, geom.start, geom.end);
|
||||
params["aspect"] = json!(across / geom.length().max(1e-12) * 100.0);
|
||||
("paint.setGradientGeom", params)
|
||||
}
|
||||
Grab::Stop { index, from, copy } => {
|
||||
let (t, d) = from.project(p);
|
||||
let t = t.clamp(0.0, 1.0) as f32;
|
||||
|
|
@ -475,7 +547,7 @@ impl Tool for GradientTool {
|
|||
(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))
|
||||
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0, focal: None }, &Gradient::default().stops))
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
|
@ -490,7 +562,7 @@ impl Tool for GradientTool {
|
|||
if let Some(g) = &self.gesture {
|
||||
return match (&g.grab, g.stop) {
|
||||
(Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop,
|
||||
(Grab::Rotate(_), _) => Cursor::Rotate,
|
||||
(Grab::Rotate { .. }, _) => Cursor::Rotate,
|
||||
(Grab::Art, _) => Cursor::Crosshair,
|
||||
_ => Cursor::Move,
|
||||
};
|
||||
|
|
@ -530,7 +602,7 @@ mod tests {
|
|||
fn graded(stops: Vec<GradientStop>) -> (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 });
|
||||
g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0, focal: None });
|
||||
d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g)));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
|
|
@ -670,7 +742,7 @@ mod tests {
|
|||
t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0)));
|
||||
// Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping).
|
||||
cx.constrain_angle = 30.0;
|
||||
let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg();
|
||||
let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0, focal: None }.angle_deg();
|
||||
for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] {
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
|
||||
let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone();
|
||||
|
|
@ -805,7 +877,7 @@ mod tests {
|
|||
#[test]
|
||||
fn annotator_follows_the_active_appearance_item() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0 };
|
||||
let geom = GradientGeom { start: Point::new(100.0, 120.0), end: Point::new(200.0, 120.0), aspect: 1.0, focal: None };
|
||||
let mut gp = GradientPaint::new(Default::default());
|
||||
gp.geom = Some(geom);
|
||||
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().paint = Paint::Gradient(Box::new(gp));
|
||||
|
|
@ -826,4 +898,30 @@ mod tests {
|
|||
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 180.0)));
|
||||
assert_eq!(exec(&a[0]).1["item"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_shows_its_extent_ellipse_aspect_handle_and_focal_dot() {
|
||||
let (mut d, s) = graded(two());
|
||||
let n = d.node_mut(s.objects[0]).unwrap();
|
||||
let Paint::Gradient(mut g) = n.appearance.fill_paint() else { unreachable!() };
|
||||
g.gradient.kind = GradientKind::Radial;
|
||||
let geom = g.geom.as_mut().unwrap();
|
||||
geom.aspect = 0.5;
|
||||
geom.set_focal(Some(Point::new(120.0, 145.0)));
|
||||
n.appearance.set_fill(Paint::Gradient(g));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let o = GradientTool::default().overlays(&cx);
|
||||
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { dashed: true, .. })).count(), 1, "the extent ellipse");
|
||||
// The aspect handle 50 pt above the centre, the focal dot where the focal point is.
|
||||
assert!(
|
||||
o.contains(&Overlay::Handle { p: Point::new(100.0, 100.0), color: BAR })
|
||||
&& o.contains(&Overlay::Handle { p: Point::new(120.0, 145.0), color: BAR })
|
||||
);
|
||||
let a = Annotator::of(&cx).unwrap();
|
||||
let hit = |x: f64, y: f64| a.hit(&cx, Point::new(x, y));
|
||||
assert_eq!((hit(120.0, 145.0), hit(104.0, 150.0), hit(100.0, 100.0)), (Some(Part::Focal), Some(Part::Start), Some(Part::Aspect)));
|
||||
assert_eq!((hit(100.0, 200.0), hit(0.0, 150.0), hit(100.0, 220.0)), (Some(Part::Rotate), Some(Part::Rotate), None));
|
||||
assert_eq!(GradientTool::default().cursor(&cx, Point::new(100.0, 200.0), Mods::default()), Cursor::Rotate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1208,7 +1208,7 @@ mod tests {
|
|||
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
|
||||
let mut g = GradientPaint::new(Default::default());
|
||||
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0 });
|
||||
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None });
|
||||
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
|
||||
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
|
||||
}
|
||||
|
|
|
|||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -155,6 +155,17 @@ delete it, hold Alt to copy it), dragging the end handle changes the vector. The
|
|||
{"name":"press_key","arguments":{"key":"Delete"}}
|
||||
```
|
||||
|
||||
A radial gradient's annotator also draws its extent: a dashed ellipse around the centre (the start, drawn as a ring)
|
||||
with a dot on it across the bar (drag it to change the aspect ratio); dragging the ellipse elsewhere rotates it.
|
||||
The dot inside the centre ring is the focal point, where the first stop sits: drag it for an off-centre radial, back
|
||||
onto the centre to centre it. `paint.setGradientGeom` sets the same things directly (`aspect` in %, `focal` in
|
||||
document coordinates or `null`; `start`/`end` may be left out), and they export as SVG `fx`/`fy` and PDF two-point
|
||||
radial shadings:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.setGradientGeom","params":{"aspect":60,"focal":[130,140]}}}
|
||||
```
|
||||
|
||||
Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected
|
||||
object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points
|
||||
(document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?,
|
||||
|
|
|
|||
Loading…
Reference in New Issue