Merge wp/B1
# Conflicts: # crates/engine/src/cmd/swatch.rs # crates/ui-egui/src/dialogs/mod.rs # crates/ui-egui/src/panels/mod.rs # docs/control-protocol.md
This commit is contained in:
commit
efc0ac31d9
|
|
@ -15,6 +15,10 @@ pub enum GradientKind {
|
|||
}
|
||||
|
||||
impl GradientKind {
|
||||
/// Parse a kind name (`linear`, `radial`, `freeform`; any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GradientKind::Linear => "Linear",
|
||||
|
|
@ -112,6 +116,80 @@ impl Gradient {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- stop editing (the Gradient panel, the annotator and agents share these) ----------
|
||||
|
||||
/// Fewest stops a gradient keeps: deleting a stop never goes below this.
|
||||
pub const MIN_STOPS: usize = 2;
|
||||
|
||||
/// Put `s` among `stops` (sorted by offset, after stops at the same offset). Returns its index.
|
||||
fn place_stop(stops: &mut Vec<GradientStop>, s: GradientStop) -> usize {
|
||||
let i = stops.iter().position(|o| o.offset > s.offset).unwrap_or(stops.len());
|
||||
stops.insert(i, s);
|
||||
i
|
||||
}
|
||||
|
||||
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
|
||||
/// the new stop's index.
|
||||
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let offset = offset.clamp(0.0, 1.0);
|
||||
let (color, opacity) = g.sample(offset);
|
||||
let mut stops = g.stops.clone();
|
||||
let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 });
|
||||
(stops, i)
|
||||
}
|
||||
|
||||
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
|
||||
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
|
||||
if stops.len() <= MIN_STOPS || i >= stops.len() {
|
||||
return None;
|
||||
}
|
||||
let mut v = stops.to_vec();
|
||||
v.remove(i);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
|
||||
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
if i >= v.len() {
|
||||
return (v, i);
|
||||
}
|
||||
let mut s = v.remove(i);
|
||||
s.offset = offset.clamp(0.0, 1.0);
|
||||
let ni = place_stop(&mut v, s);
|
||||
(v, ni)
|
||||
}
|
||||
|
||||
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
|
||||
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
let Some(&s) = v.get(i) else { return (v, i) };
|
||||
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
|
||||
(v, ni)
|
||||
}
|
||||
|
||||
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
|
||||
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
|
||||
let mut v = stops.to_vec();
|
||||
if let Some(s) = v.get_mut(i) {
|
||||
s.midpoint = m.clamp(0.13, 0.87);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
|
||||
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
Some(a.offset + (b.offset - a.offset) * a.midpoint)
|
||||
}
|
||||
|
||||
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
|
||||
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
let span = (b.offset - a.offset).max(1e-6);
|
||||
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
|
||||
}
|
||||
|
||||
/// Where the gradient sits on an object, in document coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientGeom {
|
||||
|
|
@ -127,27 +205,83 @@ fn one64() -> f64 {
|
|||
}
|
||||
|
||||
impl GradientGeom {
|
||||
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension.
|
||||
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
|
||||
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
|
||||
let c = b.center();
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
match kind {
|
||||
GradientKind::Radial => {
|
||||
let r = b.width().max(b.height()) / 2.0;
|
||||
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
|
||||
}
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 },
|
||||
_ => {
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
// Project the box corners onto the direction to cover the whole box.
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.start = a * self.start;
|
||||
self.end = a * self.end;
|
||||
|
||||
/// Map the placement through `a` so the gradient follows its object exactly.
|
||||
///
|
||||
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
|
||||
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
|
||||
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
|
||||
/// new end (on the axis nearest the mapped vector) and aspect.
|
||||
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y);
|
||||
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
|
||||
let start = a * self.start;
|
||||
let u = self.end - self.start;
|
||||
if u.hypot2() < 1e-18 {
|
||||
self.start = start;
|
||||
self.end = start;
|
||||
return;
|
||||
}
|
||||
if kind != GradientKind::Radial {
|
||||
let far = a * self.end;
|
||||
let nrm = perp(lin(perp(u)));
|
||||
self.start = start;
|
||||
self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
|
||||
return;
|
||||
}
|
||||
let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
|
||||
// Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii.
|
||||
let p = lu.x * lu.x + lw.x * lw.x;
|
||||
let q = lu.y * lu.y + lw.y * lw.y;
|
||||
let r = lu.x * lu.y + lw.x * lw.y;
|
||||
let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt();
|
||||
let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt();
|
||||
let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt();
|
||||
let (axis, len, other) = if disc <= 1e-12 * (p + q) {
|
||||
// A circle: every direction is an axis, so keep the mapped vector's.
|
||||
(lu / lu.hypot().max(1e-300), s1, s2)
|
||||
} else {
|
||||
let th = 0.5 * (2.0 * r).atan2(p - q);
|
||||
let e1 = Vec2::new(th.cos(), th.sin());
|
||||
let e2 = perp(e1);
|
||||
if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) }
|
||||
};
|
||||
let axis = if axis.dot(lu) < 0.0 { -axis } else { axis };
|
||||
self.start = start;
|
||||
self.end = start + axis * len;
|
||||
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
|
||||
}
|
||||
|
||||
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
|
||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||
let u = self.end - self.start;
|
||||
let l2 = u.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return 0.0;
|
||||
}
|
||||
let d = p - self.start;
|
||||
let along = d.dot(u) / l2;
|
||||
match kind {
|
||||
GradientKind::Radial => along.hypot(d.cross(u) / (l2 * self.aspect.max(1e-9))),
|
||||
_ => along,
|
||||
}
|
||||
}
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
let v = self.end - self.start;
|
||||
|
|
@ -179,6 +313,19 @@ impl GradientPaint {
|
|||
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
|
||||
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
|
||||
}
|
||||
/// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms
|
||||
/// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps).
|
||||
pub fn pin(&mut self, bounds: Rect) {
|
||||
if self.geom.is_none() {
|
||||
self.geom = Some(self.resolve(bounds));
|
||||
}
|
||||
}
|
||||
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
if let Some(g) = &mut self.geom {
|
||||
g.transform(a, self.gradient.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -221,4 +368,138 @@ mod tests {
|
|||
assert_eq!(g.stops[0].color.to_hex(), "#000000");
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
|
||||
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_90_gives_vertical_vector() {
|
||||
let mut g = linear((0.0, 0.0), (100.0, 0.0));
|
||||
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||||
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
|
||||
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_swaps_start_and_end() {
|
||||
let mut g = linear((0.0, 25.0), (100.0, 25.0));
|
||||
// Reflect across the vertical axis through x = 50.
|
||||
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
|
||||
g.transform(flip, GradientKind::Linear);
|
||||
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
|
||||
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0 };
|
||||
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
|
||||
assert!((g.aspect - 0.5).abs() < 1e-9);
|
||||
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
|
||||
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0 };
|
||||
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_follow_shear_and_rotation() {
|
||||
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6 };
|
||||
let mut m = g;
|
||||
m.transform(shear, kind);
|
||||
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
|
||||
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
|
||||
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
|
||||
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
|
||||
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
|
||||
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5 };
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
fn g3() -> Gradient {
|
||||
let mut g = Gradient::default();
|
||||
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_samples_color_and_sorts() {
|
||||
let g = Gradient::default();
|
||||
let (stops, i) = insert_stop(&g, 0.25);
|
||||
assert_eq!(stops.len(), 3);
|
||||
assert_eq!(i, 1);
|
||||
let r = stops[1].color.to_rgb()[0];
|
||||
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
|
||||
let (stops, i) = insert_stop(&g, 2.0);
|
||||
assert_eq!(i, 2);
|
||||
assert_eq!(stops[2].offset, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keeps_two() {
|
||||
let g = g3();
|
||||
let v = remove_stop(&g.stops, 1).unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
assert!(remove_stop(&v, 0).is_none());
|
||||
assert!(remove_stop(&g.stops, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_reorders_and_tracks_index() {
|
||||
let g = g3();
|
||||
let (v, i) = move_stop(&g.stops, 0, 0.8);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(v[1].color.to_hex(), "#ffffff");
|
||||
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
|
||||
let (v, i) = move_stop(&g.stops, 1, -3.0);
|
||||
// Clamped to 0 and placed after the existing stop at 0.
|
||||
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_the_stop_to_a_new_offset() {
|
||||
let g = g3();
|
||||
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
|
||||
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
|
||||
assert_eq!(v[1], g.stops[1], "the original stays");
|
||||
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_math() {
|
||||
let g = g3();
|
||||
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
|
||||
assert_eq!(midpoint_pos(&g.stops, 2), None);
|
||||
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
|
||||
assert!((m - 0.2).abs() < 1e-5);
|
||||
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
|
||||
let v = set_midpoint(&g.stops, 0, 0.99);
|
||||
assert_eq!(v[0].midpoint, 0.87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_fixes_the_fit_once() {
|
||||
let mut p = GradientPaint::new(Gradient::default());
|
||||
p.angle = 90.0;
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
p.pin(b);
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,16 @@ impl StrokeLayer {
|
|||
effects: vec![],
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
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.
|
||||
|
|
@ -325,6 +335,54 @@ impl Appearance {
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Gradient paints of the fills and strokes.
|
||||
fn gradients_mut(&mut self) -> impl Iterator<Item = &mut vectorcraft_color::GradientPaint> {
|
||||
self.items.iter_mut().filter_map(|i| match i {
|
||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
||||
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&mut **g),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// Does a fill or stroke carry a gradient that is fitted to the bounds on each render?
|
||||
pub fn has_unplaced_gradient(&self) -> bool {
|
||||
self.items.iter().any(|i| match i {
|
||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
||||
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
|
||||
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
|
||||
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
|
||||
for i in &mut self.items {
|
||||
match i {
|
||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.pin(bounds),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let b = s.paint_bounds(bounds);
|
||||
if let Paint::Gradient(g) = &mut s.paint {
|
||||
g.pin(b);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Map placed gradients through `a`.
|
||||
pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) {
|
||||
for g in self.gradients_mut() {
|
||||
g.transform(a);
|
||||
}
|
||||
}
|
||||
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
|
||||
/// at each gradient's centre: the start of a radial, the middle of a linear vector).
|
||||
pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
|
||||
for g in self.gradients_mut() {
|
||||
if let Some(geom) = g.geom {
|
||||
let c = if g.gradient.kind == vectorcraft_color::GradientKind::Radial { geom.start } else { geom.start.midpoint(geom.end) };
|
||||
g.transform(near(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -448,13 +448,17 @@ impl Node {
|
|||
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
|
||||
/// `scale_strokes` also scales stroke weights by the transform's mean scale.
|
||||
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
self.pin_gradients();
|
||||
}
|
||||
if scale_strokes {
|
||||
let det = a.determinant().abs().sqrt();
|
||||
if (det - 1.0).abs() > 1e-9 {
|
||||
self.appearance.scale_strokes(det);
|
||||
}
|
||||
}
|
||||
transform_paints(&mut self.appearance, a);
|
||||
self.appearance.transform_gradients(a);
|
||||
match &mut self.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
|
|
@ -516,22 +520,51 @@ impl Node {
|
|||
self.walk(&mut |_| n += 1);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_paints(ap: &mut Appearance, a: Affine) {
|
||||
use crate::appearance::AppearanceItem;
|
||||
use vectorcraft_color::Paint;
|
||||
for it in &mut ap.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p
|
||||
&& let Some(geom) = &mut g.geom
|
||||
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
|
||||
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
|
||||
pub fn pin_gradients(&mut self) {
|
||||
if self.appearance.has_unplaced_gradient()
|
||||
&& let Some(b) = self.geometric_bounds()
|
||||
{
|
||||
geom.transform(a);
|
||||
self.appearance.pin_gradients(b);
|
||||
}
|
||||
}
|
||||
/// 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)?
|
||||
fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-12 * m0.abs().max(1.0);
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -555,6 +588,29 @@ mod tests {
|
|||
assert_eq!(child.appearance.stroke_width(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
|
||||
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
|
||||
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut n = fresh();
|
||||
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);
|
||||
assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting");
|
||||
let mut n = fresh();
|
||||
n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
|
||||
let (f, s) = geoms(&n);
|
||||
// Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical.
|
||||
let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
|
||||
want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||||
let f = f.unwrap();
|
||||
assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}");
|
||||
assert!((s.unwrap().length() - 102.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names() {
|
||||
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
|
||||
"{index, opacity?, blend?, visible?, weight?, color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of the selection's appearance stack",
|
||||
has_selection,
|
||||
set_item
|
||||
),
|
||||
|
|
@ -101,18 +101,20 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.edit("Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let bounds = n.geometric_bounds();
|
||||
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
|
||||
let (pp, op, bl, vis) = match item {
|
||||
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
|
||||
let (pp, op, bl, vis, bounds) = match item {
|
||||
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible, bounds),
|
||||
AppearanceItem::Stroke(st) => {
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
st.width = w.max(0.0);
|
||||
}
|
||||
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
|
||||
let bounds = bounds.map(|b| st.paint_bounds(b));
|
||||
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible, bounds)
|
||||
}
|
||||
};
|
||||
if let Some(pa) = &paint {
|
||||
*pp = pa.clone();
|
||||
*pp = super::gradient::place_paint(pa, p, bounds);
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
@ -15,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
|
@ -24,51 +26,193 @@ 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
|
||||
),
|
||||
cmd!(
|
||||
"gradient.selectStop",
|
||||
"Select Gradient Stop",
|
||||
[],
|
||||
None,
|
||||
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}",
|
||||
always,
|
||||
select_stop
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The gradient behind the active proxy: the first selected object's (see
|
||||
/// `Node::proxy_paint`), else the default paint for new art.
|
||||
pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
|
||||
let stroke = !s.fill_active;
|
||||
let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id)));
|
||||
let paint = match first {
|
||||
Some(n) => n.proxy_paint(stroke).map(|(p, ..)| p.clone()),
|
||||
None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }),
|
||||
};
|
||||
match paint {
|
||||
Some(Paint::Gradient(g)) => Some(*g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "gradient.selectStop";
|
||||
let index = match p.get("index") {
|
||||
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
|
||||
let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len();
|
||||
if i >= n {
|
||||
return Err(bad(C, format!("no stop {i} (the gradient has {n})")));
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
};
|
||||
s.gradient_stop = index.map(|i| (i, StopOwner::of(s)));
|
||||
Ok(json!({ "index": index }))
|
||||
}
|
||||
|
||||
/// Whose gradient a selected stop belongs to: the active document, its first selected object
|
||||
/// (None: the default paint) and the proxy in front. Selecting other art or toggling the proxy
|
||||
/// leaves no stop selected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct StopOwner {
|
||||
doc: Option<usize>,
|
||||
object: Option<NodeId>,
|
||||
fill: bool,
|
||||
}
|
||||
|
||||
impl StopOwner {
|
||||
fn of(s: &Session) -> Self {
|
||||
Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active }
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The selected gradient stop (`gradient.selectStop`), while the gradient it was selected on
|
||||
/// is still the one behind the active proxy. Callers check it against the stop count (an undo
|
||||
/// can remove stops).
|
||||
pub fn selected_stop(&self) -> Option<usize> {
|
||||
self.gradient_stop.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
type Parsed<T> = std::result::Result<T, String>;
|
||||
|
||||
/// Parse a kind name.
|
||||
fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||
}
|
||||
|
||||
/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid
|
||||
/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the
|
||||
/// diamond's range. The result is sorted by offset.
|
||||
pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
let arr = v.as_array().ok_or("`stops` must be an array")?;
|
||||
if arr.len() < 2 {
|
||||
return Err("a gradient needs at least two stops".into());
|
||||
}
|
||||
let mut out = arr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, st)| {
|
||||
let num = |k: &str| st.get(k).map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number"))).transpose();
|
||||
let offset = num("offset")?.ok_or_else(|| format!("stop {i} needs `offset`"))?;
|
||||
let color = st.get("color").and_then(color_value).ok_or_else(|| format!("stop {i} needs a valid `color`"))?;
|
||||
let opacity = num("opacity")?.map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
Ok(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
color,
|
||||
opacity: opacity.clamp(0.0, 1.0) as f32,
|
||||
midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32,
|
||||
})
|
||||
})
|
||||
.collect::<Parsed<Vec<_>>>()?;
|
||||
out.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a gradient paint object (the `gradient` param of `paint.setFill`). Lossless for everything
|
||||
/// `vectorcraft_tools::params::gradient_params` writes.
|
||||
pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
if !g.is_object() {
|
||||
return Err("`gradient` must be an object".into());
|
||||
}
|
||||
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
|
||||
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
|
||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||
let aspect = aspect_param(g)?;
|
||||
let point = |k: &str| g.get(k).map(|_| point_param(g, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose();
|
||||
match (point("start")?, point("end")?) {
|
||||
(Some(start), Some(end)) => {
|
||||
let geom = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0) };
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return Err("give both `start` and `end` (or neither)".into()),
|
||||
}
|
||||
Ok(gp)
|
||||
}
|
||||
|
||||
/// The `aspect` param (a percentage) as a ratio.
|
||||
fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
|
||||
p.get("aspect")
|
||||
.map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `paint` without the placement it had on the art it came from (a swatch, or the default paint
|
||||
/// for new art): a placed gradient keeps its angle and fits each object it lands on.
|
||||
pub(crate) fn unplaced(paint: &Paint) -> Paint {
|
||||
match paint {
|
||||
Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })),
|
||||
_ => paint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` applying a swatch (whose gradient placement belongs to the art it was saved from)?
|
||||
fn applies_swatch(p: &Value) -> bool {
|
||||
p.get("swatch").is_some()
|
||||
}
|
||||
|
||||
/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is
|
||||
/// placed on the bounds so the aspect sticks, and a gradient swatch fits the object (keeping its
|
||||
/// aspect) instead of the art it was saved from.
|
||||
pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<Rect>) -> Paint {
|
||||
let Paint::Gradient(g) = paint else { return paint.clone() };
|
||||
let aspect = match g.geom {
|
||||
Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)),
|
||||
None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(),
|
||||
Some(_) => return paint.clone(),
|
||||
};
|
||||
let fitted = unplaced(paint);
|
||||
match aspect {
|
||||
Some(a) => apply_gradient_edit(&fitted, &json!({ "aspect": a }), bounds).unwrap_or(fitted),
|
||||
None => fitted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<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()),
|
||||
};
|
||||
if let Some(k) = str_param(p, "kind") {
|
||||
let kind = match k.to_ascii_lowercase().as_str() {
|
||||
"linear" => GradientKind::Linear,
|
||||
"radial" => GradientKind::Radial,
|
||||
"freeform" => GradientKind::Freeform,
|
||||
_ => return Err(format!("unknown gradient kind `{k}`")),
|
||||
};
|
||||
let kind = parse_kind(k)?;
|
||||
if kind != gp.gradient.kind {
|
||||
gp.gradient.kind = kind;
|
||||
gp.geom = None;
|
||||
}
|
||||
}
|
||||
if let Some(stops) = p.get("stops") {
|
||||
let arr = stops.as_array().ok_or("`stops` must be an array")?;
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for st in arr {
|
||||
let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?;
|
||||
let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?;
|
||||
let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5);
|
||||
out.push(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
color,
|
||||
opacity: opacity.clamp(0.0, 1.0) as f32,
|
||||
midpoint: midpoint.clamp(0.13, 0.87) as f32,
|
||||
});
|
||||
}
|
||||
if out.len() < 2 {
|
||||
return Err("a gradient needs at least two stops".into());
|
||||
}
|
||||
gp.gradient.stops = out;
|
||||
gp.gradient.sort();
|
||||
gp.gradient.stops = parse_stops(stops)?;
|
||||
gp.swatch = None;
|
||||
}
|
||||
if bool_or(p, "reverse", false) {
|
||||
|
|
@ -96,8 +240,7 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vecto
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(asp) = p.get("aspect").and_then(Value::as_f64) {
|
||||
let asp = (asp / 100.0).clamp(0.005, 327.67);
|
||||
if let Some(asp) = aspect_param(p)? {
|
||||
if gp.geom.is_none()
|
||||
&& let Some(b) = bounds
|
||||
{
|
||||
|
|
@ -130,9 +273,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),
|
||||
}
|
||||
|
|
@ -141,7 +285,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let b = n.geometric_bounds();
|
||||
let ap = &mut n.appearance;
|
||||
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
|
||||
let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) };
|
||||
match apply_gradient_edit(&cur, p, b) {
|
||||
Ok(np) => {
|
||||
if stroke {
|
||||
|
|
@ -166,10 +310,67 @@ 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()
|
||||
&& !applies_swatch(p)
|
||||
&& 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)?,
|
||||
|
|
@ -179,27 +380,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<_>>() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ mod edit;
|
|||
mod effectcmd;
|
||||
pub mod fileio;
|
||||
mod fonts;
|
||||
mod gradient;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, NodeKind};
|
||||
|
||||
use super::gradient::{place_paint, place_run_paint, run_paint_mut, unplaced};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
|
|
@ -14,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, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default (new art fits a gradient to itself)",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
|
|
@ -62,27 +63,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
|||
return Ok(Some(paint));
|
||||
}
|
||||
if let Some(g) = p.get("gradient") {
|
||||
let kind = match str_param(g, "kind") {
|
||||
Some("radial") => GradientKind::Radial,
|
||||
_ => GradientKind::Linear,
|
||||
};
|
||||
let mut grad = Gradient { kind, ..Default::default() };
|
||||
if let Some(stops) = g.get("stops").and_then(Value::as_array) {
|
||||
grad.stops = stops
|
||||
.iter()
|
||||
.filter_map(|st| {
|
||||
Some(vectorcraft_color::GradientStop {
|
||||
offset: st.get("offset")?.as_f64()? as f32,
|
||||
color: color_value(st.get("color")?)?,
|
||||
opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
|
||||
midpoint: 0.5,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
grad.sort();
|
||||
}
|
||||
let mut gp = GradientPaint::new(grad);
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?;
|
||||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
|
|
@ -94,10 +75,11 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
|||
|
||||
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?;
|
||||
// New art gets the paint fitted to itself, not placed where this one is.
|
||||
if fill {
|
||||
s.paint.fill = paint.clone();
|
||||
s.paint.fill = unplaced(&paint);
|
||||
} else {
|
||||
s.paint.stroke = paint.clone();
|
||||
s.paint.stroke = unplaced(&paint);
|
||||
}
|
||||
s.fill_active = fill;
|
||||
let ids = paint_targets(s, p)?;
|
||||
|
|
@ -108,22 +90,24 @@ 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;
|
||||
}
|
||||
let b = n.geometric_bounds();
|
||||
if fill {
|
||||
n.appearance.set_fill(paint.clone());
|
||||
n.appearance.set_fill(place_paint(&paint, p, b));
|
||||
} else {
|
||||
n.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.paint = place_paint(&paint, p, b.map(|b| st.paint_bounds(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
|
|
@ -91,23 +90,10 @@ impl Projective {
|
|||
}
|
||||
}
|
||||
|
||||
fn warp_paints(ap: &mut Appearance, pr: &Projective) {
|
||||
for it in &mut ap.items {
|
||||
let p = match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p
|
||||
&& let Some(geom) = &mut g.geom
|
||||
{
|
||||
geom.start = pr.apply(geom.start);
|
||||
geom.end = pr.apply(geom.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
|
||||
/// approximation at their centre.
|
||||
fn warp_node(n: &mut Node, pr: &Projective) {
|
||||
warp_paints(&mut n.appearance, pr);
|
||||
n.pin_gradients();
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
|
|
@ -125,11 +111,14 @@ fn warp_node(n: &mut Node, pr: &Projective) {
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
// No editable points: the affine approximation (which maps the gradients too).
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
n.transform(pr.affine_near(b.center()), false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
n.appearance.warp_gradients(&|p| pr.affine_near(p));
|
||||
}
|
||||
|
||||
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -443,6 +443,10 @@ pub struct Session {
|
|||
untitled_counter: u32,
|
||||
/// Session-level state of the menu commands (saved selections, guide lock).
|
||||
pub(crate) menu: cmd::menucmds::MenuState,
|
||||
/// The selected gradient stop (`gradient.selectStop`) and whose gradient it was selected on,
|
||||
/// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it
|
||||
/// with [`Session::selected_stop`].
|
||||
pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -468,6 +472,7 @@ impl Session {
|
|||
draw_inside: None,
|
||||
untitled_counter: 0,
|
||||
menu: Default::default(),
|
||||
gradient_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -721,6 +726,8 @@ mod tests_draw2;
|
|||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
//! Gradients through commands: lossless, validated params (M3.14), transforms (M3.15), the active
|
||||
//! proxy and type (M3.16) and the interactive annotator (M3.17).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
|
||||
match node(s, id).appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("expected a gradient fill, got {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector.
|
||||
fn three_stops() -> GradientPaint {
|
||||
let stop = |offset, color, opacity, midpoint| GradientStop { offset, color, opacity, midpoint };
|
||||
let mut g = GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Freeform,
|
||||
stops: vec![
|
||||
stop(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3),
|
||||
stop(0.4, Color::cmyk(0.1, 0.2, 0.3, 0.4), 0.5, 0.5),
|
||||
stop(1.0, Color::gray(0.25), 0.75, 0.5),
|
||||
],
|
||||
});
|
||||
g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5 });
|
||||
g.angle = g.geom.unwrap().angle_deg();
|
||||
g
|
||||
}
|
||||
|
||||
fn gradient_param(g: &GradientPaint) -> Value {
|
||||
json!({ "gradient": vectorcraft_tools::params::gradient_params(g) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_params_round_trip_through_fill_and_swatches() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let g = three_stops();
|
||||
s.execute("paint.setFill", &gradient_param(&g)).unwrap();
|
||||
let got = fill_gradient(&s, id);
|
||||
assert_eq!(got.gradient.kind, GradientKind::Freeform);
|
||||
assert_eq!(got.gradient.stops, g.gradient.stops);
|
||||
let (a, b) = (got.geom.unwrap(), g.geom.unwrap());
|
||||
assert!(a.start.distance(b.start) < 1e-9 && a.end.distance(b.end) < 1e-9 && (a.aspect - b.aspect).abs() < 1e-9, "{a:?}");
|
||||
// The default for new art keeps all but the placement (new art fits it to itself), and saving
|
||||
// it as a swatch is lossless.
|
||||
assert_eq!(s.paint.fill, Paint::Gradient(Box::new(GradientPaint { geom: None, ..got.clone() })));
|
||||
let mut q = gradient_param(&g);
|
||||
q["name"] = json!("Three");
|
||||
s.execute("swatch.new", &q).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.swatch("Three").unwrap().paint, Paint::Gradient(Box::new(got)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_gradients_are_rejected_without_editing() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let before = node(&s, id).appearance.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}]}),
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1}]}),
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"color": "#000000"}]}),
|
||||
json!({"stops": [{"offset": "a", "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}),
|
||||
json!({"stops": "red"}),
|
||||
json!({"kind": "conic"}),
|
||||
json!({"start": [0, 0]}),
|
||||
json!({"aspect": "wide"}),
|
||||
] {
|
||||
assert!(s.execute("paint.setFill", &json!({ "gradient": bad })).is_err(), "accepted {bad}");
|
||||
// `start` isn't a paint.editGradient param (it would just apply the gradient).
|
||||
if bad.get("start").is_none() {
|
||||
assert!(s.execute("paint.editGradient", &bad).is_err(), "editGradient accepted {bad}");
|
||||
}
|
||||
}
|
||||
assert_eq!(node(&s, id).appearance, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_opacity_and_offsets_are_normalised() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient": {"stops": [{"offset": 1.5, "color": "#000000", "opacity": 50}, {"offset": -1, "color": "#ffffff", "opacity": 0.25, "midpoint": 0.99}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = fill_gradient(&s, id);
|
||||
// Sorted, offsets clamped, 0..100 opacity normalised, midpoint clamped to the diamond's range.
|
||||
let st = &g.gradient.stops;
|
||||
assert_eq!((st[0].offset, st[0].opacity, st[0].midpoint), (0.0, 0.25, 0.87));
|
||||
assert_eq!((st[1].offset, st[1].opacity), (1.0, 0.5));
|
||||
assert!(g.geom.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_and_aspect_in_one_call() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 150], "end": [260, 150], "aspect": 40}})).unwrap();
|
||||
let geom = fill_gradient(&s, id).geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(260.0, 150.0)));
|
||||
assert!((geom.aspect - 0.4).abs() < 1e-9);
|
||||
// An aspect without a vector places the gradient on the object's bounds so the aspect sticks.
|
||||
s.execute("paint.setStroke", &json!({"gradient": {"kind": "radial", "aspect": 25}})).unwrap();
|
||||
let Paint::Gradient(sg) = node(&s, id).appearance.stroke_paint() else { panic!() };
|
||||
let geom = sg.geom.unwrap();
|
||||
assert_eq!(geom.start, Point::new(200.0, 150.0));
|
||||
assert!((geom.aspect - 0.25).abs() < 1e-9 && (geom.length() - 100.5).abs() < 1e-9, "fits the stroke-inflated bounds: {geom:?}");
|
||||
// appearance.setItem takes the same gradient object.
|
||||
let g = three_stops();
|
||||
let mut q = gradient_param(&g);
|
||||
q["index"] = json!(0);
|
||||
s.execute("appearance.setItem", &q).unwrap();
|
||||
assert_eq!(fill_gradient(&s, id).gradient, g.gradient);
|
||||
}
|
||||
|
||||
// ---------- M3.15: gradients follow every transform ----------
|
||||
|
||||
fn fill_geom(s: &Session, id: NodeId) -> GradientGeom {
|
||||
fill_gradient(s, id).geom.expect("placed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_reflect_and_scale_carry_unplaced_gradients() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
assert!(fill_gradient(&s, id).geom.is_none(), "a move keeps refitting");
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
let g = fill_geom(&s, id);
|
||||
assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}");
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
let r = fill_geom(&s, id);
|
||||
// The vertical vector is centred on the object, so the reflection swaps its ends.
|
||||
assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}");
|
||||
|
||||
// A circle's radial gradient becomes an ellipse under a non-uniform scale.
|
||||
let c = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
|
||||
let c = NodeId(c["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let e = fill_geom(&s, c);
|
||||
assert!((e.aspect - 0.5).abs() < 1e-9 && (e.length() - 100.0).abs() < 1e-9, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_distort_maps_gradients_once() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||||
// An affine distort (a sheared parallelogram) maps the gradient exactly like object.transform.
|
||||
s.execute("object.distort", &json!({"corners": [[20, 0], [120, 0], [100, 100], [0, 100]]})).unwrap();
|
||||
let g = fill_geom(&s, id);
|
||||
let mut want = GradientGeom::fit(GradientKind::Linear, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
|
||||
want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear);
|
||||
assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}");
|
||||
}
|
||||
|
||||
// ---------- 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);
|
||||
}
|
||||
|
||||
// ---------- M3.17: the interactive annotator ----------
|
||||
|
||||
/// A 100 × 100 rectangle at (100, 100) with a horizontal fill gradient across it, and the
|
||||
/// Gradient tool.
|
||||
fn annotated() -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"start": [100, 150], "end": [200, 150]}})).unwrap();
|
||||
s.select_tool("gradient", crate::ViewInfo::default()).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn gesture(s: &mut Session, events: &[(PointerKind, f64, f64)]) {
|
||||
for (k, x, y) in events {
|
||||
s.pointer(&PointerEvent::new(*k, *x, *y), crate::ViewInfo::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_count(s: &Session, id: NodeId) -> usize {
|
||||
fill_gradient(s, id).gradient.stops.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_stop_is_validated_and_shared() {
|
||||
let (mut s, _) = annotated();
|
||||
assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1}));
|
||||
assert_eq!(s.selected_stop(), Some(1));
|
||||
assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops");
|
||||
assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err());
|
||||
assert!(s.execute("gradient.selectStop", &json!({})).is_err());
|
||||
s.execute("gradient.selectStop", &json!({"index": null})).unwrap();
|
||||
assert_eq!(s.selected_stop(), None);
|
||||
// The stroke proxy's paint is solid: nothing to select there.
|
||||
s.execute("paint.toggleActive", &json!({})).unwrap();
|
||||
assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotator_gestures_edit_the_gradient_as_single_undo_steps() {
|
||||
let (mut s, id) = annotated();
|
||||
// A click on the bar adds a stop there and selects it.
|
||||
gesture(&mut s, &[(PointerKind::Down, 130.0, 150.0), (PointerKind::Up, 130.0, 150.0)]);
|
||||
let stops = fill_gradient(&s, id).gradient.stops;
|
||||
assert_eq!(stops.len(), 3);
|
||||
assert!((stops[1].offset - 0.3).abs() < 1e-6);
|
||||
assert_eq!(s.selected_stop(), Some(1));
|
||||
// Dragging the end handle changes only the end, as one undo step.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
gesture(
|
||||
&mut s,
|
||||
&[(PointerKind::Down, 200.0, 150.0), (PointerKind::Drag, 210.0, 160.0), (PointerKind::Drag, 220.0, 170.0), (PointerKind::Up, 220.0, 170.0)],
|
||||
);
|
||||
let g = fill_geom(&s, id);
|
||||
assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(220.0, 170.0)));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The selected stop takes Delete ahead of edit.clear; the object stays.
|
||||
let view = crate::ViewInfo::default();
|
||||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||||
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
|
||||
assert_eq!(stop_count(&s, id), 2);
|
||||
assert_eq!(s.selected_stop(), Some(1));
|
||||
// Never below two stops.
|
||||
s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap();
|
||||
assert_eq!(stop_count(&s, id), 2);
|
||||
assert!(s.doc().unwrap().doc.node(id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_stop_belongs_to_its_gradient() {
|
||||
let (mut s, a) = annotated();
|
||||
let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("gradient.selectStop", &json!({"index": 1})).unwrap();
|
||||
let view = crate::ViewInfo::default();
|
||||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||||
// Other art selected: no stop is selected there, so Delete is not the tool's.
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!(s.selected_stop(), None);
|
||||
assert!(!s.tool_claims_key(ToolKey::Delete, view));
|
||||
// Back on the art it was selected on, it is selected again; the other proxy has none.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
assert_eq!(s.selected_stop(), Some(1));
|
||||
s.execute("paint.toggleActive", &json!({})).unwrap();
|
||||
assert_eq!(s.selected_stop(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let mut g = three_stops();
|
||||
g.gradient.kind = GradientKind::Radial;
|
||||
let mut q = gradient_param(&g);
|
||||
s.execute("paint.setFill", &q).unwrap();
|
||||
q["name"] = json!("Placed");
|
||||
s.execute("swatch.new", &q).unwrap();
|
||||
// New art drawn elsewhere fits the gradient to itself rather than to the first rectangle.
|
||||
let b = rect(&mut s, 500.0, 300.0, 100.0, 100.0);
|
||||
assert_eq!(fill_gradient(&s, b).geom, None);
|
||||
// Applying the swatch fits its gradient to each object, keeping the aspect it was saved with.
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap();
|
||||
let geom = fill_geom(&s, b);
|
||||
assert_eq!(geom.start, Point::new(550.0, 350.0));
|
||||
assert!((geom.aspect - 0.5).abs() < 1e-9 && (geom.length() - 50.0).abs() < 1e-9, "{geom:?}");
|
||||
// On type, in text space.
|
||||
let t = text(&mut s, 100.0, 400.0);
|
||||
s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap();
|
||||
let (_, doc) = node(&s, t).proxy_gradient(false).unwrap();
|
||||
let tb = node(&s, t).geometric_bounds().unwrap();
|
||||
assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}");
|
||||
}
|
||||
|
|
@ -78,6 +78,8 @@ 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,
|
||||
gradient_stop: self.selected_stop(),
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
|
@ -108,6 +110,11 @@ impl Session {
|
|||
self.tool.busy()
|
||||
}
|
||||
|
||||
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
|
||||
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
|
||||
self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
}
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
|
||||
if let Some(d) = self.active() {
|
||||
|
|
|
|||
|
|
@ -166,9 +166,10 @@ impl Headless {
|
|||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
||||
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
|
||||
let tk = tool_key(key);
|
||||
// A busy tool (pen path in progress, shape being dragged) gets its keys first.
|
||||
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
|
||||
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
|
||||
if let Some(k) = tk
|
||||
&& self.session.tool_busy()
|
||||
&& (self.session.tool_busy() || self.session.tool_claims_key(k, self.view))
|
||||
{
|
||||
let mut out = vec![];
|
||||
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -31,3 +31,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fileio;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
//! The Gradient tool's annotator through MCP: `pointer_gesture` edits stops and the vector,
|
||||
//! `press_key` reaches the selected stop.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::color::{GradientPaint, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Backend, Headless, call_tool};
|
||||
|
||||
fn ok(h: &mut Headless, name: &str, args: Value) -> Value {
|
||||
let r = call_tool(h, name, &args);
|
||||
assert!(!r.is_error, "{name} {args}: {r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The fill gradient of the selected object.
|
||||
fn gradient(h: &Headless) -> GradientPaint {
|
||||
let st = h.session.doc().unwrap();
|
||||
match st.doc.node(st.selection.objects[0]).unwrap().appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("not a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn offsets(g: &GradientPaint) -> Vec<f32> {
|
||||
g.gradient.stops.iter().map(|s| (s.offset * 100.0).round()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_annotator_end_to_end() {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap();
|
||||
ok(&mut h, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
|
||||
ok(&mut h, "run_command", json!({"command": "paint.setFill", "params": {"gradient": {"start": [100, 150], "end": [200, 150]}}}));
|
||||
// A click on the bar adds a stop at 30 %.
|
||||
let click = json!({"tool": "gradient", "events": [{"kind": "down", "x": 130, "y": 150}, {"kind": "up", "x": 130, "y": 150}]});
|
||||
ok(&mut h, "pointer_gesture", click);
|
||||
assert_eq!(offsets(&gradient(&h)), vec![0.0, 30.0, 100.0]);
|
||||
// Drag the new stop (its chip sits 10 px under the bar) to 60 %.
|
||||
let drag = json!({"events": [{"kind": "down", "x": 130, "y": 160}, {"kind": "drag", "x": 145, "y": 160}, {"kind": "drag", "x": 160, "y": 160}, {"kind": "up", "x": 160, "y": 160}]});
|
||||
ok(&mut h, "pointer_gesture", drag);
|
||||
assert_eq!(offsets(&gradient(&h)), vec![0.0, 60.0, 100.0]);
|
||||
// The dragged stop stays selected: Delete removes it (and leaves the rectangle).
|
||||
let r = ok(&mut h, "press_key", json!({"key": "Delete"}));
|
||||
assert_eq!(r["handledBy"], "tool", "{r}");
|
||||
assert_eq!(offsets(&gradient(&h)), vec![0.0, 100.0]);
|
||||
// The end handle changes only the end.
|
||||
let end = json!({"events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "drag", "x": 230, "y": 120}, {"kind": "up", "x": 230, "y": 120}]});
|
||||
ok(&mut h, "pointer_gesture", end);
|
||||
let g = gradient(&h).geom.unwrap();
|
||||
assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(230.0, 120.0)));
|
||||
// Double-clicking a stop asks for its popover.
|
||||
let dbl = ok(&mut h, "pointer_gesture", json!({"events": [{"kind": "doubleclick", "x": 100, "y": 160}]}));
|
||||
assert_eq!(dbl["requests"][0]["dialog"], "gradientStop", "{dbl}");
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
@ -605,7 +606,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() }));
|
||||
|
|
@ -629,7 +630,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;
|
||||
|
|
@ -815,7 +816,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);
|
||||
}
|
||||
|
|
@ -830,7 +834,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);
|
||||
}
|
||||
|
|
@ -330,7 +330,7 @@ impl Writer<'_> {
|
|||
|
||||
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let mut geom = g.resolve(bounds);
|
||||
geom.transform(self.xf);
|
||||
geom.transform(self.xf, g.gradient.kind);
|
||||
let (s, e) = (geom.start, geom.end);
|
||||
let head = match g.gradient.kind {
|
||||
GradientKind::Radial => {
|
||||
|
|
|
|||
|
|
@ -91,11 +91,6 @@ fn aff(t: usvg::Transform) -> Affine {
|
|||
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
|
||||
}
|
||||
|
||||
/// Linear part of `m` applied to `v`.
|
||||
fn lin(m: Affine, v: Vec2) -> Vec2 {
|
||||
(m * v.to_point()) - (m * Point::ZERO)
|
||||
}
|
||||
|
||||
fn is_plain(g: &usvg::Group) -> bool {
|
||||
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
|
||||
}
|
||||
|
|
@ -298,17 +293,10 @@ impl Importer {
|
|||
if lg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id()));
|
||||
}
|
||||
let t = m * aff(lg.transform());
|
||||
let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64));
|
||||
let start = t * p1;
|
||||
let far = t * p2;
|
||||
// Isolines (perpendicular to the vector in gradient space) stay parallel under an
|
||||
// affine map; the document gradient vector is the normal to their image.
|
||||
let dir = p2 - p1;
|
||||
let iso = lin(t, Vec2::new(-dir.y, dir.x));
|
||||
let nrm = Vec2::new(-iso.y, iso.x);
|
||||
let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
|
||||
gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 })
|
||||
let mut geom =
|
||||
GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0 };
|
||||
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
|
||||
gp(GradientKind::Linear, stops(lg), geom)
|
||||
}
|
||||
usvg::Paint::RadialGradient(rg) => {
|
||||
if rg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
|
|
@ -317,14 +305,10 @@ impl Importer {
|
|||
if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 {
|
||||
self.warn(format!("gradient '{}': focal point ignored", rg.id()));
|
||||
}
|
||||
let t = m * aff(rg.transform());
|
||||
let r = rg.r().get() as f64;
|
||||
let c = Point::new(rg.cx() as f64, rg.cy() as f64);
|
||||
let vx = lin(t, Vec2::new(r, 0.0));
|
||||
let vy = lin(t, Vec2::new(0.0, r));
|
||||
let start = t * c;
|
||||
let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 };
|
||||
gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect })
|
||||
let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 };
|
||||
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
|
||||
gp(GradientKind::Radial, stops(rg), geom)
|
||||
}
|
||||
usvg::Paint::Pattern(pt) => {
|
||||
self.warn(format!("pattern '{}' not supported; painted as none", pt.id()));
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ mod width;
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use crate::Tool;
|
||||
|
|
@ -42,11 +41,15 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Apply a point map to every anchor and handle of `n` and its descendants. Gradient vectors are
|
||||
/// mapped too; objects without editable points (text, images, symbols, meshes, live objects) get
|
||||
/// the map's affine approximation at their centre. Live shapes become plain paths.
|
||||
/// Apply a point map to every anchor and handle of `n` and its descendants. Gradients (pinned
|
||||
/// first if unplaced) follow the map's affine approximation at their centre; objects without
|
||||
/// editable points (text, images, symbols, meshes, live objects) get the approximation at their
|
||||
/// centre. Live shapes become plain paths.
|
||||
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
||||
warp_paints(&mut n.appearance, f);
|
||||
let bounds = n.geometric_bounds();
|
||||
// Finite-difference step: 5% of the object (at least half a point).
|
||||
let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5));
|
||||
n.pin_gradients();
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
|
|
@ -67,27 +70,13 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
let a = affine_near(f, b.center(), (b.width().max(b.height()) * 0.05).max(0.5));
|
||||
n.transform(a, false);
|
||||
if let Some(b) = bounds {
|
||||
n.transform(affine_near(f, b.center(), eps), false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn warp_paints(ap: &mut Appearance, f: &dyn Fn(Point) -> Point) {
|
||||
for it in &mut ap.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(fl) => &mut fl.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p
|
||||
&& let Some(geom) = &mut g.geom
|
||||
{
|
||||
geom.start = f(geom.start);
|
||||
geom.end = f(geom.end);
|
||||
}
|
||||
}
|
||||
n.appearance.warp_gradients(&|p| affine_near(f, p, eps));
|
||||
}
|
||||
|
||||
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
|
||||
|
|
@ -136,6 +125,8 @@ pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> vector
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
|
||||
#[test]
|
||||
|
|
@ -170,6 +161,17 @@ mod tests {
|
|||
let _ = PathData::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_pins_and_maps_gradients_once() {
|
||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut n =
|
||||
Node::path(vectorcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(grad, Paint::None, 0.0));
|
||||
warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0));
|
||||
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!() };
|
||||
let geom = g.geom.expect("pinned before the warp");
|
||||
assert!(geom.start.distance(Point::new(0.0, 6.0)) < 1e-9 && geom.end.distance(Point::new(20.0, 6.0)) < 1e-9, "{geom:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_near_recovers_affine_maps() {
|
||||
let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub mod draw2;
|
|||
pub mod extra;
|
||||
pub mod guides;
|
||||
pub mod meshblend;
|
||||
pub mod params;
|
||||
pub mod pen;
|
||||
pub mod select;
|
||||
pub mod shape;
|
||||
|
|
@ -155,6 +156,10 @@ 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,
|
||||
/// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator.
|
||||
pub gradient_stop: Option<usize>,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
|
@ -186,6 +191,8 @@ pub enum Overlay {
|
|||
Measure { p: Point, text: String },
|
||||
/// Translucent filled quad (text selection highlight), RGBA.
|
||||
Highlight { quad: [Point; 4], color: [u8; 4] },
|
||||
/// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.
|
||||
Swatch { p: Point, color: [u8; 4], selected: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -214,6 +221,10 @@ pub enum Cursor {
|
|||
ZoomOut,
|
||||
Eyedropper,
|
||||
NotAllowed,
|
||||
/// Over the gradient annotator's bar: a click adds a stop.
|
||||
AddStop,
|
||||
/// A gradient stop dragged off the bar: releasing deletes it.
|
||||
RemoveStop,
|
||||
}
|
||||
|
||||
/// A tool state machine.
|
||||
|
|
@ -238,6 +249,11 @@ pub trait Tool: Send {
|
|||
fn busy(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
|
||||
/// with a stop selected takes Delete and the arrows)?
|
||||
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
|
||||
false
|
||||
}
|
||||
/// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed.
|
||||
fn wants_text(&self) -> bool {
|
||||
false
|
||||
|
|
@ -324,6 +340,8 @@ pub(crate) mod testutil {
|
|||
snap_to_pixel: false,
|
||||
snap_to_point: true,
|
||||
corner_widgets: true,
|
||||
fill_active: true,
|
||||
gradient_stop: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
//! Command params written by tools and panels: the inverse of the engine's paint parsers, so a
|
||||
//! paint read from the document and sent back through a command arrives unchanged.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, GradientPaint, GradientStop};
|
||||
|
||||
/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`.
|
||||
pub fn color_json(c: &Color) -> Value {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => json!([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}),
|
||||
Color::Gray { k } => json!({"gray": k}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint.
|
||||
pub fn stops_json(stops: &[GradientStop]) -> Value {
|
||||
Value::Array(
|
||||
stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A gradient paint as the `gradient` param of `paint.setFill` / `swatch.new`: kind, stops (with
|
||||
/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%).
|
||||
pub fn gradient_params(g: &GradientPaint) -> Value {
|
||||
let mut v = json!({
|
||||
"kind": g.gradient.kind.label().to_lowercase(),
|
||||
"angle": g.angle,
|
||||
"stops": stops_json(&g.gradient.stops),
|
||||
});
|
||||
if let Some(geom) = g.geom {
|
||||
v["start"] = json!([geom.start.x, geom.start.y]);
|
||||
v["end"] = json!([geom.end.x, geom.end.y]);
|
||||
v["aspect"] = json!(geom.aspect * 100.0);
|
||||
}
|
||||
if let Some(s) = &g.swatch {
|
||||
v["swatch"] = json!(s);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn gradient_params_carry_placement_only_when_placed() {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Default::default() });
|
||||
let v = gradient_params(&g);
|
||||
assert_eq!(v["kind"], "radial");
|
||||
assert!(v.get("start").is_none() && v.get("aspect").is_none());
|
||||
assert_eq!(v["stops"][0]["midpoint"], json!(0.5));
|
||||
g.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5 });
|
||||
g.swatch = Some("Sky".into());
|
||||
let v = gradient_params(&g);
|
||||
assert_eq!((v["start"].clone(), v["end"].clone(), v["aspect"].clone()), (json!([1.0, 2.0]), json!([3.0, 4.0]), json!(50.0)));
|
||||
assert_eq!(v["swatch"], "Sky");
|
||||
assert_eq!(color_json(&Color::gray(0.25)), json!({"gray": 0.25}));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,17 +14,14 @@ use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
|
|||
|
||||
pub struct EyedropperTool;
|
||||
|
||||
/// The colour of `paint` at `p` (gradients are sampled along their vector).
|
||||
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
|
||||
/// their aspect ratio).
|
||||
pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p: Point) -> Option<Color> {
|
||||
match paint {
|
||||
Paint::Solid { color, .. } => Some(*color),
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(bounds?);
|
||||
let v = geom.end - geom.start;
|
||||
let t = match g.gradient.kind {
|
||||
vectorcraft_color::GradientKind::Radial => (p - geom.start).hypot() / v.hypot().max(1e-9),
|
||||
_ => (p - geom.start).dot(v) / v.hypot2().max(1e-9),
|
||||
};
|
||||
let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?;
|
||||
let t = geom.param_at(g.gradient.kind, p);
|
||||
Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0)
|
||||
}
|
||||
_ => None,
|
||||
|
|
@ -45,15 +42,11 @@ impl Tool for EyedropperTool {
|
|||
let src = paint_owner(cx.doc, h.leaf);
|
||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
||||
if ev.mods.shift {
|
||||
let (fill, stroke) = match &n.kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => {
|
||||
let st = t.runs.first().map(|r| r.style.clone());
|
||||
(st.as_ref().map(|s| s.fill.clone()).unwrap_or_default(), st.map(|s| s.stroke).unwrap_or_default())
|
||||
}
|
||||
_ => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
|
||||
};
|
||||
let paint = if h.kind == HitKind::Stroke || fill.is_none() { stroke } else { fill };
|
||||
return match paint_color_at(&paint, n.geometric_bounds(), ev.pos) {
|
||||
let fill_none = n.proxy_paint(false).is_none_or(|(p, ..)| p.is_none());
|
||||
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none) else { return vec![] };
|
||||
// Sample in the paint's own space (text space for type runs) and box.
|
||||
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
|
||||
return match paint_color_at(paint, Some(bounds), p) {
|
||||
Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))],
|
||||
None => vec![],
|
||||
};
|
||||
|
|
@ -98,4 +91,38 @@ mod tests {
|
|||
assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff");
|
||||
assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_gradients_are_sampled_in_text_space() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
// Type turned 90°: its baseline runs down from (100, 100).
|
||||
let mut t = TextObject::point(Point::ZERO, "Gradient", CharStyle { size: 20.0, ..Default::default() });
|
||||
t.xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(std::f64::consts::FRAC_PI_2);
|
||||
let mut g = GradientPaint::new(Gradient::default());
|
||||
g.geom = Some(GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 1.0 });
|
||||
t.runs[0].style.fill = Paint::Gradient(Box::new(g));
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
// 80 % along the baseline (text space (80, -5)).
|
||||
let a = EyedropperTool.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_sample_honours_aspect() {
|
||||
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient {
|
||||
kind: vectorcraft_color::GradientKind::Radial,
|
||||
..Default::default()
|
||||
});
|
||||
// Radius 100 along x, 50 across: 50 pt above the centre is already the outer (black) edge.
|
||||
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5 });
|
||||
let paint = Paint::Gradient(Box::new(g));
|
||||
assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000");
|
||||
assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
//! Gradient tool (G).
|
||||
//! Gradient tool (G) and the gradient annotator.
|
||||
//!
|
||||
//! 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
|
||||
//! 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.
|
||||
//! 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 press targets the object under the
|
||||
//! pointer; a click inside selected art applies the gradient from that point.
|
||||
//!
|
||||
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
|
||||
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
|
||||
//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add
|
||||
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
|
||||
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
|
||||
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
|
||||
//! Backspace removes it (never below two stops) and the arrow keys nudge it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::paint_owner;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
|
@ -16,102 +26,412 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
|||
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
|
||||
const LIGHT: [u8; 3] = [0xf0, 0xf0, 0xf0];
|
||||
|
||||
// Annotator layout and hit radii, in screen pixels.
|
||||
/// Start and end handles.
|
||||
const HANDLE: f64 = 5.0;
|
||||
/// The rotate zone reaches this far past the end handle.
|
||||
const ROTATE: f64 = 14.0;
|
||||
/// Stop chips sit this far below the bar.
|
||||
const STOP_GAP: f64 = 10.0;
|
||||
const STOP_HIT: f64 = 6.0;
|
||||
/// Midpoint diamonds sit this far above the bar.
|
||||
const MID_GAP: f64 = 7.0;
|
||||
const MID_HIT: f64 = 5.0;
|
||||
/// The bar (where a click adds a stop) reaches this far above it, and down through the stop row.
|
||||
const BAR_HIT: f64 = 4.0;
|
||||
/// A stop dragged farther than this from the bar is deleted on release.
|
||||
const OFF_BAR: f64 = 24.0;
|
||||
/// A press becomes a drag after this much movement.
|
||||
const DRAG_START: f64 = 3.0;
|
||||
/// Arrow-key nudges of the selected stop (Shift: the big step).
|
||||
const NUDGE: f32 = 0.01;
|
||||
const NUDGE_BIG: f32 = 0.1;
|
||||
|
||||
/// The gradient annotator: the gradient behind the active proxy of the first selected object that
|
||||
/// has one, placed in document coordinates.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Annotator {
|
||||
pub geom: GradientGeom,
|
||||
pub gradient: Gradient,
|
||||
}
|
||||
|
||||
/// A part of the annotator.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
Stop(usize),
|
||||
/// The midpoint diamond after stop `i`.
|
||||
Mid(usize),
|
||||
Start,
|
||||
End,
|
||||
/// Just past the end: rotates the vector about the start.
|
||||
Rotate,
|
||||
/// The bar at an offset (0..1): a click adds a stop there.
|
||||
Bar(f32),
|
||||
}
|
||||
|
||||
impl Annotator {
|
||||
pub fn of(cx: &ToolContext) -> Option<Self> {
|
||||
cx.selection.objects.iter().find_map(|id| {
|
||||
let (g, geom) = cx.doc.node(*id)?.proxy_gradient(!cx.fill_active)?;
|
||||
Some(Self { geom, gradient: g.gradient.clone() })
|
||||
})
|
||||
}
|
||||
/// An annotator showing `stops` on `geom` (the state a drag started from).
|
||||
fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self {
|
||||
Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } }
|
||||
}
|
||||
fn vector(&self) -> Vec2 {
|
||||
self.geom.end - self.geom.start
|
||||
}
|
||||
/// The point at offset `t` along the bar.
|
||||
pub fn at(&self, t: f64) -> Point {
|
||||
self.geom.start + self.vector() * t
|
||||
}
|
||||
/// Unit normal to the bar, towards the stop row.
|
||||
fn normal(&self) -> Vec2 {
|
||||
let v = self.vector();
|
||||
let len = v.hypot();
|
||||
if len < 1e-12 { Vec2::new(0.0, 1.0) } else { Vec2::new(-v.y, v.x) / len }
|
||||
}
|
||||
/// Where stop `i`'s chip sits.
|
||||
pub fn stop_point(&self, cx: &ToolContext, i: usize) -> Option<Point> {
|
||||
let s = self.gradient.stops.get(i)?;
|
||||
Some(self.at(s.offset as f64) + self.normal() * cx.tol(STOP_GAP))
|
||||
}
|
||||
/// Where the midpoint diamond after stop `i` sits.
|
||||
pub fn mid_point(&self, cx: &ToolContext, i: usize) -> Option<Point> {
|
||||
Some(self.at(midpoint_pos(&self.gradient.stops, i)? as f64) - self.normal() * cx.tol(MID_GAP))
|
||||
}
|
||||
/// `p` as (offset along the bar, signed distance from it towards the stop row).
|
||||
fn project(&self, p: Point) -> (f64, f64) {
|
||||
let v = self.vector();
|
||||
let l2 = v.hypot2();
|
||||
let d = p - self.geom.start;
|
||||
if l2 < 1e-18 {
|
||||
return (0.0, d.hypot());
|
||||
}
|
||||
(d.dot(v) / l2, d.dot(self.normal()))
|
||||
}
|
||||
/// The part under `p`: stops, then midpoints, the handles, the rotate zone and the bar.
|
||||
pub fn hit(&self, cx: &ToolContext, p: Point) -> Option<Part> {
|
||||
let near = |q: Option<Point>, r: f64| q.is_some_and(|q| q.distance(p) <= cx.tol(r));
|
||||
let n = self.gradient.stops.len();
|
||||
// The topmost (last drawn) chip wins where chips overlap.
|
||||
if let Some(i) = (0..n).rev().find(|i| near(self.stop_point(cx, *i), STOP_HIT)) {
|
||||
return Some(Part::Stop(i));
|
||||
}
|
||||
if let Some(i) = (0..n.saturating_sub(1)).find(|i| near(self.mid_point(cx, *i), MID_HIT)) {
|
||||
return Some(Part::Mid(i));
|
||||
}
|
||||
if near(Some(self.geom.end), HANDLE) {
|
||||
return Some(Part::End);
|
||||
}
|
||||
if near(Some(self.geom.start), HANDLE) {
|
||||
return Some(Part::Start);
|
||||
}
|
||||
let (t, d) = self.project(p);
|
||||
if t > 1.0 && near(Some(self.geom.end), ROTATE) {
|
||||
return Some(Part::Rotate);
|
||||
}
|
||||
let across = -cx.tol(BAR_HIT)..=cx.tol(STOP_GAP + STOP_HIT);
|
||||
if self.vector().hypot() > 1e-12 && (0.0..=1.0).contains(&t) && across.contains(&d) {
|
||||
return Some(Part::Bar(t as f32));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What a press grabbed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Grab {
|
||||
/// The art (or empty canvas): a click applies the gradient from there, a drag draws a vector.
|
||||
Art,
|
||||
/// The start handle or the bar: a drag moves the gradient (a click on the bar adds a stop).
|
||||
Move {
|
||||
geom: GradientGeom,
|
||||
bar: Option<f32>,
|
||||
},
|
||||
End(GradientGeom),
|
||||
Rotate(GradientGeom),
|
||||
Stop {
|
||||
index: usize,
|
||||
from: Annotator,
|
||||
copy: bool,
|
||||
},
|
||||
Mid {
|
||||
index: usize,
|
||||
from: Annotator,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Gesture {
|
||||
grab: Grab,
|
||||
/// Where the press happened.
|
||||
at: Point,
|
||||
/// Moved past the drag threshold (an interaction is open).
|
||||
began: bool,
|
||||
/// The vector drawn so far (Art drags).
|
||||
vector: Option<(Point, Point)>,
|
||||
/// Where a dragged stop ends up (selected on release): its index, or None while it is dragged
|
||||
/// off the bar.
|
||||
stop: Option<Option<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GradientTool {
|
||||
drag: Option<(Point, Point, bool)>,
|
||||
gesture: Option<Gesture>,
|
||||
}
|
||||
|
||||
/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient fill.
|
||||
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()?);
|
||||
Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect()))
|
||||
})
|
||||
}
|
||||
|
||||
fn annotator_overlays(cx: &ToolContext, a: Point, b: Point, stops: &[f32]) -> Vec<Overlay> {
|
||||
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
|
||||
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![
|
||||
Overlay::Line { a, b, color: BAR, dashed: false },
|
||||
Overlay::Line { a: a + Vec2::new(0.0, cx.tol(1.0)), b: b + Vec2::new(0.0, cx.tol(1.0)), color: LIGHT, dashed: false },
|
||||
Overlay::Handle { p: a, color: BAR },
|
||||
Overlay::Anchor { p: b, color: BAR, filled: true, size: 7.0 },
|
||||
Overlay::Line { a: s, b: e, color: BAR, dashed: false },
|
||||
Overlay::Line { a: s + shade, b: e + shade, color: LIGHT, dashed: false },
|
||||
Overlay::Handle { p: s, color: BAR },
|
||||
Overlay::Anchor { p: e, color: BAR, filled: true, size: 7.0 },
|
||||
];
|
||||
let v = b - a;
|
||||
let len = v.hypot();
|
||||
if len > 1e-9 {
|
||||
let n = Vec2::new(-v.y, v.x) / len * cx.tol(6.0);
|
||||
for s in stops {
|
||||
let p = a + v * *s as f64;
|
||||
o.push(Overlay::Line { a: p, b: p + n, color: BAR, dashed: false });
|
||||
}
|
||||
let len = a.vector().hypot();
|
||||
if len < 1e-12 {
|
||||
return o;
|
||||
}
|
||||
let r = cx.tol(4.0);
|
||||
let (along, across) = (a.vector() / len * r, a.normal() * r);
|
||||
for i in 0..a.gradient.stops.len().saturating_sub(1) {
|
||||
let Some(c) = a.mid_point(cx, i) else { continue };
|
||||
let path = super::polygon(&[c - along, c - across, c + along, c + across], true);
|
||||
o.push(Overlay::Path { path, color: BAR, width: 1.0, dashed: false });
|
||||
}
|
||||
for (i, st) in a.gradient.stops.iter().enumerate() {
|
||||
let Some(p) = a.stop_point(cx, i) else { continue };
|
||||
o.push(Overlay::Line { a: a.at(st.offset as f64), b: p, color: BAR, dashed: false });
|
||||
o.push(Overlay::Swatch { p, color: st.color.to_rgba8(st.opacity), selected: cx.gradient_stop == Some(i) });
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The vector a click applies to `id`: its current gradient's, else the default fit on its box.
|
||||
fn click_vector(cx: &ToolContext, id: NodeId) -> Option<Vec2> {
|
||||
let stroke = !cx.fill_active;
|
||||
let n = cx.doc.node(id)?;
|
||||
if let Some((_, g)) = n.proxy_gradient(stroke) {
|
||||
return Some(g.end - g.start);
|
||||
}
|
||||
let (to_doc, b) = match n.proxy_paint(stroke) {
|
||||
Some((_, a, b)) => (a, b),
|
||||
None => (Affine::IDENTITY, n.geometric_bounds()?),
|
||||
};
|
||||
let mut g = GradientGeom::fit(GradientKind::Linear, b, 0.0);
|
||||
g.transform(to_doc, GradientKind::Linear);
|
||||
Some(g.end - g.start)
|
||||
}
|
||||
|
||||
impl GradientTool {
|
||||
fn geom_params(cx: &ToolContext, start: Point, end: Point) -> Value {
|
||||
json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active })
|
||||
}
|
||||
fn stops_params(cx: &ToolContext, stops: &[GradientStop]) -> Value {
|
||||
json!({ "stops": crate::params::stops_json(stops), "stroke": !cx.fill_active })
|
||||
}
|
||||
fn select_stop(i: usize) -> Action {
|
||||
Action::Exec("gradient.selectStop".into(), json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn press(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||||
let annotator = Annotator::of(cx);
|
||||
let mut out = vec![];
|
||||
let grab = match annotator.as_ref().and_then(|a| a.hit(cx, p).map(|part| (a, part))) {
|
||||
Some((a, Part::Stop(index))) => {
|
||||
out.push(Self::select_stop(index));
|
||||
Grab::Stop { index, from: a.clone(), copy: mods.alt }
|
||||
}
|
||||
Some((a, Part::Mid(index))) => Grab::Mid { index, from: a.clone() },
|
||||
Some((a, Part::Start)) => Grab::Move { geom: a.geom, bar: None },
|
||||
Some((a, Part::Bar(t))) => Grab::Move { geom: a.geom, bar: Some(t) },
|
||||
Some((a, Part::End)) => Grab::End(a.geom),
|
||||
Some((a, Part::Rotate)) => Grab::Rotate(a.geom),
|
||||
None => {
|
||||
if cx.selection.is_empty() {
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
|
||||
out.push(Action::Exec("select.set".into(), json!({ "ids": [paint_owner(cx.doc, h.leaf).0] })));
|
||||
}
|
||||
Grab::Art
|
||||
}
|
||||
};
|
||||
self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None });
|
||||
out
|
||||
}
|
||||
|
||||
fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||||
let Some(g) = &mut self.gesture else { return vec![] };
|
||||
let mut out = vec![];
|
||||
if !g.began {
|
||||
if p.distance(g.at) < cx.tol(DRAG_START) {
|
||||
return out;
|
||||
}
|
||||
g.began = true;
|
||||
out.push(Action::Begin("Gradient".into()));
|
||||
}
|
||||
let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to };
|
||||
let (cmd, params) = match &g.grab {
|
||||
Grab::Art => {
|
||||
let end = snap(g.at, p);
|
||||
g.vector = Some((g.at, end));
|
||||
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
|
||||
}
|
||||
Grab::Move { geom, .. } => {
|
||||
let d = p - g.at;
|
||||
("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d))
|
||||
}
|
||||
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))),
|
||||
Grab::Rotate(geom) => {
|
||||
let dir = snap(geom.start, p) - geom.start;
|
||||
let len = dir.hypot();
|
||||
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
|
||||
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
|
||||
}
|
||||
Grab::Stop { index, from, copy } => {
|
||||
let (t, d) = from.project(p);
|
||||
let t = t.clamp(0.0, 1.0) as f32;
|
||||
let stops = &from.gradient.stops;
|
||||
let removed = (!copy && d.abs() > cx.tol(OFF_BAR)).then(|| remove_stop(stops, *index)).flatten();
|
||||
let (stops, at) = match removed {
|
||||
Some(v) => (v, None),
|
||||
None => {
|
||||
let (v, i) = if *copy { duplicate_stop(stops, *index, t) } else { move_stop(stops, *index, t) };
|
||||
(v, Some(i))
|
||||
}
|
||||
};
|
||||
g.stop = Some(at);
|
||||
("paint.editGradient", Self::stops_params(cx, &stops))
|
||||
}
|
||||
Grab::Mid { index, from } => {
|
||||
let stops = &from.gradient.stops;
|
||||
let m = midpoint_from_pos(stops, *index, from.project(p).0.clamp(0.0, 1.0) as f32).unwrap_or(0.5);
|
||||
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
|
||||
}
|
||||
};
|
||||
out.push(Action::Preview(cmd.into(), params));
|
||||
out
|
||||
}
|
||||
|
||||
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
let Some(g) = self.gesture.take() else { return vec![] };
|
||||
if g.began {
|
||||
let mut out = vec![Action::Commit];
|
||||
match (g.grab, g.stop) {
|
||||
(Grab::Stop { .. }, Some(Some(i))) => out.push(Self::select_stop(i)),
|
||||
// Dragged off the bar: the stop that slid into its place is selected.
|
||||
(Grab::Stop { index, from, .. }, Some(None)) => out.push(Self::select_stop(index.min(from.gradient.stops.len() - 2))),
|
||||
_ => {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match g.grab {
|
||||
// A click on the bar adds a stop there.
|
||||
Grab::Move { bar: Some(t), .. } => {
|
||||
let Some(a) = Annotator::of(cx) else { return vec![] };
|
||||
let (stops, i) = insert_stop(&a.gradient, t);
|
||||
vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &stops)), Self::select_stop(i)]
|
||||
}
|
||||
// A click inside selected art applies the gradient from that point.
|
||||
Grab::Art => {
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return vec![] };
|
||||
let id = paint_owner(cx.doc, h.leaf);
|
||||
let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] };
|
||||
let mut params = Self::geom_params(cx, p, p + v);
|
||||
params["ids"] = json!([id.0]);
|
||||
vec![Action::Exec("paint.setGradientGeom".into(), params)]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for GradientTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"gradient"
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some_and(|d| d.2)
|
||||
self.gesture.as_ref().is_some_and(|g| g.began)
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let mut out = vec![];
|
||||
if cx.selection.is_empty() {
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
|
||||
let id = paint_owner(cx.doc, h.leaf);
|
||||
out.push(Action::Exec("select.set".into(), json!({ "ids": [id.0] })));
|
||||
}
|
||||
self.drag = Some((p, p, false));
|
||||
out
|
||||
PointerKind::Down => self.press(cx, ev.pos, ev.mods),
|
||||
PointerKind::Drag => self.drag(cx, ev.pos, ev.mods),
|
||||
PointerKind::Up => self.release(cx, ev.pos),
|
||||
PointerKind::DoubleClick => {
|
||||
let Some(a) = Annotator::of(cx) else { return vec![] };
|
||||
let Some(Part::Stop(i)) = a.hit(cx, ev.pos) else { return vec![] };
|
||||
let at = a.stop_point(cx, i).unwrap_or(ev.pos);
|
||||
vec![Self::select_stop(i), Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some((start, _, began)) = self.drag else { return vec![] };
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Gradient".into()));
|
||||
}
|
||||
let end = if ev.mods.shift { start + vectorcraft_geom::constrain_angle(p - start, 45.0) } else { p };
|
||||
self.drag = Some((start, end, true));
|
||||
out.push(Action::Preview("paint.setGradientGeom".into(), json!({ "start": [start.x, start.y], "end": [end.x, end.y] })));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some((_, _, true)) => vec![Action::Commit],
|
||||
_ => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
PointerKind::Move => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right)
|
||||
&& cx.gradient_stop.is_some_and(|i| Annotator::of(cx).is_some_and(|a| i < a.gradient.stops.len()))
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.busy() {
|
||||
self.drag = None;
|
||||
self.gesture = None;
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
vec![]
|
||||
if self.busy() || !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
let (Some(a), Some(i)) = (Annotator::of(cx), cx.gradient_stop) else { return vec![] };
|
||||
let stops = &a.gradient.stops;
|
||||
let (new, sel) = match key {
|
||||
// Never below two stops: Delete then does nothing.
|
||||
ToolKey::Delete | ToolKey::Backspace => match remove_stop(stops, i) {
|
||||
Some(v) => {
|
||||
let sel = i.min(v.len() - 1);
|
||||
(v, sel)
|
||||
}
|
||||
None => return vec![],
|
||||
},
|
||||
_ => {
|
||||
let step = if mods.shift { NUDGE_BIG } else { NUDGE };
|
||||
move_stop(stops, i, stops[i].offset + if key == ToolKey::Left { -step } else { step })
|
||||
}
|
||||
};
|
||||
vec![Action::Exec("paint.editGradient".into(), Self::stops_params(cx, &new)), Self::select_stop(sel)]
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match (self.drag, annotator(cx)) {
|
||||
(_, Some((a, b, stops))) => annotator_overlays(cx, a, b, &stops),
|
||||
(Some((a, b, true)), None) => annotator_overlays(cx, a, b, &[0.0, 1.0]),
|
||||
match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
|
||||
(Some(a), _) => annotator_overlays(cx, &a),
|
||||
// A vector drawn on a paint that isn't a gradient yet.
|
||||
(None, Some((s, e))) => {
|
||||
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if let Some(g) = &self.gesture {
|
||||
return match (&g.grab, g.stop) {
|
||||
(Grab::Stop { .. }, Some(None)) => Cursor::RemoveStop,
|
||||
(Grab::Rotate(_), _) => Cursor::Rotate,
|
||||
(Grab::Art, _) => Cursor::Crosshair,
|
||||
_ => Cursor::Move,
|
||||
};
|
||||
}
|
||||
match Annotator::of(cx).and_then(|a| a.hit(cx, p)) {
|
||||
Some(Part::Bar(_)) => Cursor::AddStop,
|
||||
Some(Part::Rotate) => Cursor::Rotate,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::Crosshair,
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
|
@ -123,7 +443,58 @@ impl Tool for GradientTool {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
use vectorcraft_color::{GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Document, Selection};
|
||||
|
||||
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
|
||||
PointerEvent::new(kind, p.x, p.y)
|
||||
}
|
||||
|
||||
fn with(m: Mods, e: PointerEvent) -> PointerEvent {
|
||||
e.with_mods(m)
|
||||
}
|
||||
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false };
|
||||
|
||||
/// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150).
|
||||
fn graded(stops: Vec<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 });
|
||||
d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g)));
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
(d, s)
|
||||
}
|
||||
|
||||
fn two() -> Vec<GradientStop> {
|
||||
Gradient::default().stops
|
||||
}
|
||||
|
||||
fn three() -> Vec<GradientStop> {
|
||||
insert_stop(&Gradient::default(), 0.5).0
|
||||
}
|
||||
|
||||
fn preview(a: &[Action]) -> &Value {
|
||||
a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v) } else { None }).unwrap_or_else(|| panic!("no preview in {a:?}"))
|
||||
}
|
||||
|
||||
fn exec(a: &Action) -> (&str, &Value) {
|
||||
match a {
|
||||
Action::Exec(c, v) => (c.as_str(), v),
|
||||
a => panic!("not an Exec: {a:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn point(v: &Value) -> Point {
|
||||
Point::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap())
|
||||
}
|
||||
|
||||
/// Stop offsets in percent (rounded: offsets travel as f32).
|
||||
fn offsets(v: &Value) -> Vec<f64> {
|
||||
v["stops"].as_array().unwrap().iter().map(|s| (s["offset"].as_f64().unwrap() * 100.0).round()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_sets_gradient_vector_and_constrains() {
|
||||
|
|
@ -134,7 +505,7 @@ mod tests {
|
|||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 152.0).with_mods(Mods { shift: true, ..Default::default() }));
|
||||
let a = t.pointer(&cx, &with(SHIFT, PointerEvent::new(PointerKind::Drag, 190.0, 152.0)));
|
||||
assert_eq!(a[0], Action::Begin("Gradient".into()));
|
||||
let Action::Preview(c, v) = &a[1] else { panic!() };
|
||||
assert_eq!(c, "paint.setGradientGeom");
|
||||
|
|
@ -145,6 +516,26 @@ 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() {
|
||||
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 = Annotator::of(&cx).unwrap();
|
||||
assert_eq!((a.geom.start, a.geom.end, a.gradient.stops.len()), (Point::new(99.5, 150.0), Point::new(200.5, 150.0), 2));
|
||||
let mut t = GradientTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 120.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 120.0));
|
||||
assert_eq!(preview(&a)["stroke"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_targets_hit_object() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
|
@ -156,4 +547,156 @@ mod tests {
|
|||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_handle_changes_only_the_end_and_the_start_moves_both() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
|
||||
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(220.0, 170.0)))).clone();
|
||||
assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(100.0, 150.0), Point::new(220.0, 170.0)));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(220.0, 170.0))), vec![Action::Commit]);
|
||||
// The start handle moves the whole vector.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(100.0, 150.0)));
|
||||
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(110.0, 140.0)))).clone();
|
||||
assert_eq!((point(&v["start"]), point(&v["end"])), (Point::new(110.0, 140.0), Point::new(210.0, 140.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_end_rotates_and_shift_snaps() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
assert_eq!(t.cursor(&cx, Point::new(210.0, 151.0), Mods::default()), Cursor::Rotate);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(210.0, 151.0)));
|
||||
let end = point(&preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, Point::new(103.0, 60.0)))))["end"]);
|
||||
// Snapped to straight up, keeping the 100 pt length.
|
||||
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bar_click_adds_a_stop_and_selects_it() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
assert_eq!(t.cursor(&cx, Point::new(130.0, 150.0), Mods::default()), Cursor::AddStop);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, Point::new(130.0, 150.0))).is_empty());
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0)));
|
||||
let (c, v) = exec(&a[0]);
|
||||
assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0]));
|
||||
assert_eq!(a[1], GradientTool::select_stop(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_drag_along_the_bar_off_it_to_delete_and_alt_copies() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 0).unwrap();
|
||||
let mut t = GradientTool::default();
|
||||
// Pressing a stop selects it; dragging moves it.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, chip)), vec![GradientTool::select_stop(0)]);
|
||||
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(140.0, chip.y))))), vec![40.0, 100.0]);
|
||||
assert_eq!(t.cursor(&cx, chip, Mods::default()), Cursor::Move);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(140.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(0)]);
|
||||
// Two stops never lose one: dragged off the bar, the stop stays.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, chip));
|
||||
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 260.0))))), vec![0.0, 100.0]);
|
||||
t.pointer(&cx, &ev(PointerKind::Up, Point::new(100.0, 260.0)));
|
||||
// Alt-drag leaves the original and drags a copy.
|
||||
t.pointer(&cx, &with(ALT, ev(PointerKind::Down, chip)));
|
||||
assert_eq!(offsets(preview(&t.pointer(&cx, &with(ALT, ev(PointerKind::Drag, Point::new(150.0, chip.y)))))), vec![0.0, 50.0, 100.0]);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, chip.y))), vec![Action::Commit, GradientTool::select_stop(1)]);
|
||||
|
||||
// With three stops, dragging the middle one off the bar deletes it.
|
||||
let (d, s) = graded(three());
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, chip));
|
||||
assert_eq!(offsets(preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(150.0, 200.0))))), vec![0.0, 100.0]);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 200.0), Mods::default()), Cursor::RemoveStop);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 200.0))), vec![Action::Commit, GradientTool::select_stop(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diamonds_move_midpoints() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let diamond = Annotator::of(&cx).unwrap().mid_point(&cx, 0).unwrap();
|
||||
let mut t = GradientTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, diamond));
|
||||
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(130.0, diamond.y)))).clone();
|
||||
assert!((v["stops"][0]["midpoint"].as_f64().unwrap() - 0.3).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_arrows_act_on_the_selected_stop() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
assert!(!t.claims_key(&cx, ToolKey::Delete), "no stop selected");
|
||||
cx.gradient_stop = Some(1);
|
||||
assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace) && !t.claims_key(&cx, ToolKey::Enter));
|
||||
// Two stops: Delete is claimed but does nothing.
|
||||
assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty());
|
||||
let a = t.key(&cx, ToolKey::Left, SHIFT);
|
||||
let (c, v) = exec(&a[0]);
|
||||
assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 90.0]));
|
||||
assert_eq!(a[1], GradientTool::select_stop(1));
|
||||
// With three stops Delete removes the selected one and selects its successor.
|
||||
let (d, s) = graded(three());
|
||||
let mut cx = crate::testutil::cx(&d, &s, &p);
|
||||
cx.gradient_stop = Some(1);
|
||||
let a = t.key(&cx, ToolKey::Backspace, Mods::default());
|
||||
assert_eq!(offsets(exec(&a[0]).1), vec![0.0, 100.0]);
|
||||
assert_eq!(a[1], GradientTool::select_stop(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_inside_the_art_applies_the_gradient_from_there() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GradientTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(150.0, 120.0)));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(150.0, 120.0)));
|
||||
let (c, v) = exec(&a[0]);
|
||||
assert_eq!(c, "paint.setGradientGeom");
|
||||
// The default fit's 100 pt horizontal vector, starting at the click.
|
||||
assert_eq!((point(&v["start"]), point(&v["end"]), &v["ids"]), (Point::new(150.0, 120.0), Point::new(250.0, 120.0), &json!([id.0])));
|
||||
// Outside the art a click does nothing.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, Point::new(400.0, 400.0)));
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_on_a_stop_opens_its_popover() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let chip = Annotator::of(&cx).unwrap().stop_point(&cx, 1).unwrap();
|
||||
let a = GradientTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, chip));
|
||||
assert_eq!(a[0], GradientTool::select_stop(1));
|
||||
assert_eq!(a[1], Action::Dialog("gradientStop".into(), json!({"index": 1, "x": chip.x, "y": chip.y})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_draw_a_chip_per_stop_marking_the_selected_one() {
|
||||
let (d, s) = graded(two());
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.gradient_stop = Some(1);
|
||||
let o = GradientTool::default().overlays(&cx);
|
||||
let chips: Vec<bool> = o.iter().filter_map(|o| if let Overlay::Swatch { selected, .. } = o { Some(*selected) } else { None }).collect();
|
||||
assert_eq!(chips, vec![false, true]);
|
||||
assert_eq!(o.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1, "one midpoint diamond");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,6 +335,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
|||
Cursor::ZoomOut => C::ZoomOut,
|
||||
Cursor::Eyedropper => C::Crosshair,
|
||||
Cursor::NotAllowed => C::NotAllowed,
|
||||
Cursor::AddStop => C::Copy,
|
||||
Cursor::RemoveStop => C::NotAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,6 +881,17 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
|||
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
|
||||
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
|
||||
}
|
||||
Overlay::Swatch { p: pt, color, selected } => {
|
||||
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
|
||||
// any art, and the accent ring marks the selected stop.
|
||||
let c = xf.to_screen(*pt);
|
||||
p.circle_filled(c, 6.0, Color32::WHITE);
|
||||
p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]));
|
||||
p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32)));
|
||||
if *selected {
|
||||
p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,17 @@ fn corner_radius(p: &Painter, o: Pos2) {
|
|||
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
|
||||
}
|
||||
|
||||
/// The gradient annotator's stop cursors: the arrow with a plus (add a stop) or minus (delete it)
|
||||
/// badge.
|
||||
fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
||||
arrow(p, o, false);
|
||||
let b = o + vec2(13.0, 13.0);
|
||||
line(p, b, b + vec2(6.0, 0.0));
|
||||
if add {
|
||||
line(p, b + vec2(3.0, -3.0), b + vec2(3.0, 3.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn ibeam(p: &Painter, o: Pos2) {
|
||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||
|
|
@ -137,6 +148,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
|||
Cursor::PenClose => pen(painter, p, "o"),
|
||||
Cursor::PenContinue => pen(painter, p, "/"),
|
||||
Cursor::Text => ibeam(painter, p),
|
||||
Cursor::AddStop => stop_badge(painter, p, true),
|
||||
Cursor::RemoveStop => stop_badge(painter, p, false),
|
||||
_ => return false,
|
||||
}
|
||||
let _ = pos2;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it
|
||||
//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and
|
||||
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
|
||||
//! closes on Escape or a click elsewhere.
|
||||
//!
|
||||
//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`).
|
||||
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::gradient::move_stop;
|
||||
use vectorcraft_color::{Color, GradientStop, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{DialogResult, DialogSpec};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::canvas::Xf;
|
||||
use crate::panels::gradient::stops_json;
|
||||
use crate::panels::{active_paint, live_run};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm);
|
||||
|
||||
/// The stops of the gradient behind the active proxy and the selected stop's index.
|
||||
fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
|
||||
let Paint::Gradient(g) = active_paint(app) else { return None };
|
||||
let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?;
|
||||
Some((g.gradient.stops, i))
|
||||
}
|
||||
|
||||
/// Write `stops` to the gradient and select stop `i`.
|
||||
fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) {
|
||||
let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active });
|
||||
live_run(app, "Gradient", "paint.editGradient", params, phase);
|
||||
if phase == Live::Released && app.session.selected_stop() != Some(i) {
|
||||
app.run("gradient.selectStop", json!({ "index": i })).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// `stops` with stop `i` given `color`.
|
||||
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
|
||||
stops[i].color = color;
|
||||
stops
|
||||
}
|
||||
|
||||
/// Solid colours of the document's swatches (groups included), with their names.
|
||||
fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> {
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
d.swatches.iter().chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())).filter_map(|s| Some((s.name.clone(), s.paint.color()?))).collect()
|
||||
}
|
||||
|
||||
fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(d) = app.ui.dialog.clone() else { return };
|
||||
// The stop went away (undo, another object selected): nothing left to edit.
|
||||
let Some((stops, i)) = selected(app) else {
|
||||
app.ui.dialog = None;
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
|
||||
let pos =
|
||||
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0));
|
||||
let swatches = d.str("tab") == "swatches";
|
||||
let mut tab = None;
|
||||
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(pos)
|
||||
.constrain(true)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(250.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, on) in [("palette", "Color", !swatches), ("swatch-book", "Swatches", swatches)] {
|
||||
if widgets::icon_button(ui, icon, tip, on, 24.0).clicked() {
|
||||
tab = Some(if icon == "palette" { "color" } else { "swatches" });
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if swatches {
|
||||
swatch_grid(app, ui, &stops, i);
|
||||
} else {
|
||||
// The Color panel edits the selected stop of the active gradient.
|
||||
crate::panels::color::show(app, ui);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let s = stops[i];
|
||||
widgets::dim_label(ui, "Opacity:");
|
||||
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
|
||||
let mut v2 = stops.clone();
|
||||
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
|
||||
apply(app, &v2, i, Live::Released);
|
||||
}
|
||||
widgets::dim_label(ui, "Location:");
|
||||
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
|
||||
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
|
||||
apply(app, &v2, ni, Live::Released);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.response;
|
||||
if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) {
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
}
|
||||
// The double-click that opened the popover lands on its chip, outside it: only a later click
|
||||
// elsewhere closes it.
|
||||
if resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Document swatches as tiles: a click gives the stop that colour.
|
||||
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientStop], i: usize) {
|
||||
let colors = swatch_colors(app);
|
||||
if colors.is_empty() {
|
||||
widgets::dim_label(ui, "The document has no colour swatches.");
|
||||
return;
|
||||
}
|
||||
let mut pick = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
for (name, c) in &colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered());
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
pick = Some(*c);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(c) = pick {
|
||||
apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released);
|
||||
}
|
||||
}
|
||||
|
||||
/// OK (agents): apply the `color`, `opacity` and `location` fields that are set, then close.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?;
|
||||
if let Some(hex) = d.fields.get("color").and_then(Value::as_str) {
|
||||
let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
|
||||
stops = recolor(stops, i, c);
|
||||
}
|
||||
if d.fields.contains_key("opacity") {
|
||||
stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
if d.fields.contains_key("location") {
|
||||
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
|
||||
}
|
||||
apply(app, &stops, i, Live::Released);
|
||||
app.ui.dialog = None;
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A rectangle with a three-stop gradient from (100, 150) to (200, 150), the Gradient tool
|
||||
/// and stop 1 selected.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#000000"}]);
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [100, 150], "end": [200, 150]}})).unwrap();
|
||||
app.select_tool("gradient");
|
||||
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
|
||||
selected(app).unwrap().0
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer and the shortcuts.
|
||||
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
super::super::show(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn key(k: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_stop_opens_the_popover_which_draws_both_tabs() {
|
||||
let mut app = app();
|
||||
let view = app.view_info();
|
||||
let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom);
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view);
|
||||
let d = app.ui.dialog.clone().expect("the popover opened");
|
||||
assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into()));
|
||||
assert_eq!(app.session.selected_stop(), Some(1));
|
||||
frame(&mut app, vec![]);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches"));
|
||||
frame(&mut app, vec![]);
|
||||
assert!(app.ui.dialog.is_some(), "still open");
|
||||
// Escape closes it.
|
||||
frame(&mut app, vec![key(egui::Key::Escape)]);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_applies_colour_opacity_and_location() {
|
||||
let mut app = app();
|
||||
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160, "color": "#ff0000", "opacity": 40, "location": 80})));
|
||||
assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": 1}));
|
||||
let s = stops(&app);
|
||||
assert_eq!((s[1].color.to_hex(), s[1].opacity, (s[1].offset * 100.0).round()), ("#ff0000".to_string(), 0.4, 80.0));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
// Moving it past the last stop keeps it selected at its new index.
|
||||
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100})));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!((app.session.selected_stop(), stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string()));
|
||||
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"})));
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
}
|
||||
|
||||
fn click(at: egui::Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opening_double_click_keeps_it_open_and_a_later_click_elsewhere_closes_it() {
|
||||
let mut app = app();
|
||||
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160})));
|
||||
// The frame the double-click lands in (the popover opens beside the pointer, not under it).
|
||||
let at = egui::pos2(5.0, 5.0);
|
||||
let double = vec![egui::Event::PointerMoved(at), click(at, true), click(at, false), click(at, true), click(at, false)];
|
||||
frame(&mut app, double);
|
||||
assert!(app.ui.dialog.is_some(), "the opening double-click must not close it");
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(at), click(at, true), click(at, false)]);
|
||||
assert!(app.ui.dialog.is_none(), "a click elsewhere closes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_popover_closes_when_its_stop_goes_away() {
|
||||
let mut app = app();
|
||||
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160})));
|
||||
app.run("gradient.selectStop", json!({"index": null})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_selected_stop_instead_of_the_object() {
|
||||
let mut app = app();
|
||||
frame(&mut app, vec![key(egui::Key::Delete)]);
|
||||
assert_eq!(stops(&app).len(), 2);
|
||||
frame(&mut app, vec![key(egui::Key::Backspace)]);
|
||||
assert_eq!(stops(&app).len(), 2, "never below two stops");
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle stays");
|
||||
// The arrows nudge it.
|
||||
let before = stops(&app)[1].offset;
|
||||
frame(&mut app, vec![key(egui::Key::ArrowLeft)]);
|
||||
assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ mod effect;
|
|||
mod export_for_screens;
|
||||
mod form;
|
||||
pub mod new_color_group;
|
||||
mod gradient_stop;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
mod path_ops;
|
||||
|
|
@ -135,6 +136,7 @@ registry! {
|
|||
Confirm: [confirm::KIND] => confirm::SPEC,
|
||||
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
|
||||
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
|
||||
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options
|
||||
//! and artboard options.
|
||||
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options,
|
||||
//! artboard options and the gradient stop popover.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -47,6 +47,8 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
|
|||
}
|
||||
Dialog::new(kind, base)
|
||||
}
|
||||
// Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip.
|
||||
"gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})),
|
||||
_ => return,
|
||||
};
|
||||
app.ui.dialog = Some(d);
|
||||
|
|
|
|||
|
|
@ -159,11 +159,11 @@ enum Target {
|
|||
Stop { paint: Paint, index: usize, color: Color },
|
||||
}
|
||||
|
||||
fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
||||
fn target(app: &VectorcraftApp) -> Target {
|
||||
let p = active_paint(app);
|
||||
match &p {
|
||||
Paint::Gradient(g) => {
|
||||
let i = super::gradient::selected_stop(ui.ctx()).unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
|
||||
let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
|
||||
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
|
||||
Target::Stop { paint: p.clone(), index: i, color }
|
||||
}
|
||||
|
|
@ -196,7 +196,7 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live)
|
|||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let tgt = target(app, ui);
|
||||
let tgt = target(app);
|
||||
let color = match &tgt {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
|
|
@ -376,7 +376,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
set_pstate(ui.ctx(), "color-hide-options", !hidden);
|
||||
}
|
||||
ui.separator();
|
||||
let tgt = target(app, ui);
|
||||
let tgt = target(app);
|
||||
let color = match &tgt {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
|
|
|
|||
|
|
@ -4,95 +4,32 @@
|
|||
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
|
||||
use super::{active_paint, color_json, live_run, pstate, set_pstate};
|
||||
use super::{active_paint, live_run, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// Minimum number of stops (Illustrator won't delete below two).
|
||||
pub const MIN_STOPS: usize = 2;
|
||||
/// Dragging a stop this far below the ramp removes it.
|
||||
pub const REMOVE_DISTANCE: f32 = 28.0;
|
||||
|
||||
/// The Gradient panel's selected stop (shared with the Color panel).
|
||||
pub fn selected_stop(ctx: &egui::Context) -> Option<usize> {
|
||||
pstate::<Option<usize>>(ctx, "grad-stop")
|
||||
}
|
||||
fn select_stop(ctx: &egui::Context, i: Option<usize>) {
|
||||
set_pstate(ctx, "grad-stop", i);
|
||||
/// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the
|
||||
/// Color panel and agents). Run after the edit that creates it.
|
||||
fn select_stop(app: &mut VectorcraftApp, i: usize) {
|
||||
app.run("gradient.selectStop", json!({ "index": i })).ok();
|
||||
}
|
||||
|
||||
// ---------- pure stop math (unit-tested) ----------
|
||||
|
||||
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
|
||||
/// the new stop's index.
|
||||
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let offset = offset.clamp(0.0, 1.0);
|
||||
let (color, opacity) = g.sample(offset);
|
||||
let mut stops = g.stops.clone();
|
||||
let idx = stops.iter().position(|s| s.offset > offset).unwrap_or(stops.len());
|
||||
stops.insert(idx, GradientStop { offset, color, opacity, midpoint: 0.5 });
|
||||
(stops, idx)
|
||||
}
|
||||
|
||||
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
|
||||
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
|
||||
if stops.len() <= MIN_STOPS || i >= stops.len() {
|
||||
return None;
|
||||
}
|
||||
let mut v = stops.to_vec();
|
||||
v.remove(i);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
|
||||
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
if i >= v.len() {
|
||||
return (v, i);
|
||||
}
|
||||
let mut s = v.remove(i);
|
||||
s.offset = offset.clamp(0.0, 1.0);
|
||||
let idx = v.iter().position(|o| o.offset > s.offset).unwrap_or(v.len());
|
||||
v.insert(idx, s);
|
||||
(v, idx)
|
||||
}
|
||||
|
||||
/// Set the midpoint between stop `i` and `i + 1` (clamped to Illustrator's 13–87 %).
|
||||
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
|
||||
let mut v = stops.to_vec();
|
||||
if let Some(s) = v.get_mut(i) {
|
||||
s.midpoint = m.clamp(0.13, 0.87);
|
||||
}
|
||||
v
|
||||
}
|
||||
// ---------- pure ramp math (unit-tested) ----------
|
||||
|
||||
/// Offset (0..1) of an x position on a ramp spanning `left..left + width`.
|
||||
pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 {
|
||||
((x - left) / width.max(1.0)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
|
||||
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
Some(a.offset + (b.offset - a.offset) * a.midpoint)
|
||||
}
|
||||
|
||||
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
|
||||
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
|
||||
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
|
||||
let span = (b.offset - a.offset).max(1e-6);
|
||||
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
|
||||
}
|
||||
|
||||
/// Stops as `paint.editGradient` JSON.
|
||||
pub fn stops_json(stops: &[GradientStop]) -> Value {
|
||||
Value::Array(
|
||||
stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(),
|
||||
)
|
||||
}
|
||||
pub use vectorcraft_tools::params::stops_json;
|
||||
|
||||
// ---------- UI ----------
|
||||
|
||||
|
|
@ -188,7 +125,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ramp(app, ui, &g.gradient, is_grad);
|
||||
ui.add_space(4.0);
|
||||
// Stop fields.
|
||||
let sel = selected_stop(ui.ctx()).filter(|i| *i < g.gradient.stops.len());
|
||||
let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len());
|
||||
ui.horizontal(|ui| {
|
||||
let enabled = is_grad && sel.is_some();
|
||||
let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied();
|
||||
|
|
@ -209,16 +146,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
&& let Some(i) = sel
|
||||
{
|
||||
let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32);
|
||||
select_stop(ui.ctx(), Some(ni));
|
||||
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
|
||||
select_stop(app, ni);
|
||||
}
|
||||
let can_del = enabled && g.gradient.stops.len() > MIN_STOPS;
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
|
||||
&& let Some(i) = sel
|
||||
&& let Some(stops) = remove_stop(&g.gradient.stops, i)
|
||||
{
|
||||
select_stop(ui.ctx(), Some(i.min(stops.len() - 1)));
|
||||
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
|
||||
select_stop(app, i.min(stops.len() - 1));
|
||||
}
|
||||
});
|
||||
if let Some(i) = sel
|
||||
|
|
@ -273,7 +210,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
}
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
let x_of = |o: f32| bar.left() + o * bar.width();
|
||||
let sel = selected_stop(ui.ctx());
|
||||
let sel = app.session.selected_stop();
|
||||
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
|
||||
let stops = &g.stops;
|
||||
// While dragging, edits are computed against the stops as they were when the drag began (the
|
||||
|
|
@ -281,6 +218,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") };
|
||||
let origin = if origin.len() == stops.len() { origin } else { stops.clone() };
|
||||
let mut changed: Option<(Vec<GradientStop>, Live)> = None;
|
||||
// The stop to select once `changed` is applied.
|
||||
let mut select: Option<usize> = None;
|
||||
// Midpoint diamonds.
|
||||
for i in 0..stops.len().saturating_sub(1) {
|
||||
let Some(p) = midpoint_pos(stops, i) else { continue };
|
||||
|
|
@ -329,7 +268,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
continue;
|
||||
}
|
||||
if resp.clicked() || resp.drag_started() {
|
||||
select_stop(ui.ctx(), Some(i));
|
||||
select = Some(i);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
drag = Drag::Stop(i);
|
||||
|
|
@ -344,12 +283,12 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
drag = Drag::None;
|
||||
if removing {
|
||||
if let Some(v) = remove_stop(&origin, i) {
|
||||
select_stop(ui.ctx(), Some(i.min(v.len() - 1)));
|
||||
select = Some(i.min(v.len() - 1));
|
||||
changed = Some((v, Live::Released));
|
||||
}
|
||||
} else {
|
||||
let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
|
||||
select_stop(ui.ctx(), Some(ni));
|
||||
select = Some(ni);
|
||||
changed = Some((v, Live::Released));
|
||||
}
|
||||
} else if removing {
|
||||
|
|
@ -373,7 +312,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
{
|
||||
if is_grad {
|
||||
let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width()));
|
||||
select_stop(ui.ctx(), Some(i));
|
||||
select = Some(i);
|
||||
changed = Some((v, Live::Released));
|
||||
} else {
|
||||
edit(app, json!({}), Live::Released);
|
||||
|
|
@ -387,6 +326,9 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
if let Some((v, phase)) = changed {
|
||||
edit(app, json!({"stops": stops_json(&v)}), phase);
|
||||
}
|
||||
if let Some(i) = select {
|
||||
select_stop(app, i);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
|
@ -404,72 +346,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let d = Gradient::default();
|
||||
edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released);
|
||||
}
|
||||
let _ = Color::BLACK;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn g3() -> Gradient {
|
||||
let mut g = Gradient::default();
|
||||
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_samples_color_and_sorts() {
|
||||
let g = Gradient::default();
|
||||
let (stops, i) = insert_stop(&g, 0.25);
|
||||
assert_eq!(stops.len(), 3);
|
||||
assert_eq!(i, 1);
|
||||
let r = stops[1].color.to_rgb()[0];
|
||||
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
|
||||
let (stops, i) = insert_stop(&g, 2.0);
|
||||
assert_eq!(i, 2);
|
||||
assert_eq!(stops[2].offset, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keeps_two() {
|
||||
let g = g3();
|
||||
let v = remove_stop(&g.stops, 1).unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
assert!(remove_stop(&v, 0).is_none());
|
||||
assert!(remove_stop(&g.stops, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_reorders_and_tracks_index() {
|
||||
let g = g3();
|
||||
let (v, i) = move_stop(&g.stops, 0, 0.8);
|
||||
assert_eq!(i, 1);
|
||||
assert_eq!(v[1].color.to_hex(), "#ffffff");
|
||||
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
|
||||
let (v, i) = move_stop(&g.stops, 1, -3.0);
|
||||
// Clamped to 0 and placed after the existing stop at 0.
|
||||
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_math() {
|
||||
let g = g3();
|
||||
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
|
||||
assert_eq!(midpoint_pos(&g.stops, 2), None);
|
||||
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
|
||||
assert!((m - 0.2).abs() < 1e-5);
|
||||
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
|
||||
let v = set_midpoint(&g.stops, 0, 0.99);
|
||||
assert_eq!(v[0].midpoint, 0.87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_and_json() {
|
||||
assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25);
|
||||
assert_eq!(x_to_offset(-5.0, 0.0, 200.0), 0.0);
|
||||
assert_eq!(x_to_offset(500.0, 0.0, 200.0), 1.0);
|
||||
let j = stops_json(&g3().stops);
|
||||
assert_eq!(j.as_array().unwrap().len(), 3);
|
||||
let j = stops_json(&Gradient::default().stops);
|
||||
assert_eq!(j.as_array().unwrap().len(), 2);
|
||||
assert_eq!(j[1]["midpoint"], json!(0.5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,25 +266,15 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
|
||||
/// A colour as command JSON, keeping its model.
|
||||
pub(crate) fn color_json(c: &Color) -> Value {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => json!([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}),
|
||||
Color::Gray { k } => json!({"gray": k}),
|
||||
}
|
||||
}
|
||||
pub(crate) use vectorcraft_tools::params::color_json;
|
||||
|
||||
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` or `{pattern}`).
|
||||
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` (lossless) or `{pattern}`).
|
||||
pub(crate) fn paint_params(p: &Paint) -> Value {
|
||||
match p {
|
||||
Paint::None => json!({"none": true}),
|
||||
Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}),
|
||||
Paint::Solid { color, .. } => json!({"color": color_json(color)}),
|
||||
Paint::Gradient(g) => json!({"gradient": {
|
||||
"kind": g.gradient.kind.label().to_lowercase(),
|
||||
"angle": g.angle,
|
||||
"stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::<Vec<_>>()
|
||||
}}),
|
||||
Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}),
|
||||
Paint::Pattern { pattern, .. } => json!({"pattern": pattern}),
|
||||
}
|
||||
}
|
||||
|
|
@ -322,5 +312,6 @@ mod tests {
|
|||
assert_eq!(p["gradient"]["kind"], "linear");
|
||||
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"}));
|
||||
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
|
||||
// Delete/Backspace remove it, ←/→ nudge it).
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
for (k, tk) in
|
||||
[(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]
|
||||
{
|
||||
if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
|
||||
let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Command shortcuts.
|
||||
let mut fire: Option<&'static str> = None;
|
||||
for (sc, id) in all_shortcuts() {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
|
||||
|
||||
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
|
||||
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
|
||||
|
|
|
|||
12
docs/mcp.md
12
docs/mcp.md
|
|
@ -114,6 +114,18 @@ Drive a tool like a mouse:
|
|||
{"kind":"down","x":100,"y":100},{"kind":"drag","x":150,"y":140},{"kind":"up","x":200,"y":180}]}}
|
||||
```
|
||||
|
||||
Edit a gradient with the Gradient tool's annotator (the bar runs along the vector; stop chips sit 10 px under
|
||||
it, midpoint diamonds 7 px above): a click on the bar adds a stop, dragging a chip moves it (drag it off the bar to
|
||||
delete it, hold Alt to copy it), dragging the end handle changes the vector. The selected stop
|
||||
(`gradient.selectStop`) takes Delete and ←/→:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"start":[100,150],"end":[200,150]}}}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"gradient","events":[{"kind":"down","x":130,"y":150},{"kind":"up","x":130,"y":150}]}}
|
||||
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":130,"y":160},{"kind":"drag","x":160,"y":160},{"kind":"up","x":160,"y":160}]}}
|
||||
{"name":"press_key","arguments":{"key":"Delete"}}
|
||||
```
|
||||
|
||||
Raw protocol (for debugging):
|
||||
|
||||
```sh
|
||||
|
|
|
|||
Loading…
Reference in New Issue