mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
M3.15: gradients follow every transform — rotate, reflect, shear, non-uniform scale and Free Distort/warps pin an unplaced gradient to its fit first (fills on the bounds, strokes on the stroke-inflated box; moves and uniform scales keep refitting); linear vectors keep their isolines and radial gradients map their ellipse (principal axes give the new end and aspect); the radial angle places the vector before the gradient is placed; the eyedropper samples radials honouring aspect; SVG import shares the mapping; warped text, images and symbols no longer map their gradients twice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
61ddc1ce34
commit
81f86a9acc
+153
-11
@@ -131,27 +131,83 @@ fn one64() -> f64 {
|
||||
}
|
||||
|
||||
impl GradientGeom {
|
||||
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension.
|
||||
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
|
||||
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
|
||||
let c = b.center();
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
match kind {
|
||||
GradientKind::Radial => {
|
||||
let r = b.width().max(b.height()) / 2.0;
|
||||
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
|
||||
}
|
||||
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0 },
|
||||
_ => {
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
// Project the box corners onto the direction to cover the whole box.
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.start = a * self.start;
|
||||
self.end = a * self.end;
|
||||
|
||||
/// Map the placement through `a` so the gradient follows its object exactly.
|
||||
///
|
||||
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
|
||||
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
|
||||
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
|
||||
/// new end (on the axis nearest the mapped vector) and aspect.
|
||||
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y);
|
||||
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
|
||||
let start = a * self.start;
|
||||
let u = self.end - self.start;
|
||||
if u.hypot2() < 1e-18 {
|
||||
self.start = start;
|
||||
self.end = start;
|
||||
return;
|
||||
}
|
||||
if kind != GradientKind::Radial {
|
||||
let far = a * self.end;
|
||||
let nrm = perp(lin(perp(u)));
|
||||
self.start = start;
|
||||
self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
|
||||
return;
|
||||
}
|
||||
let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
|
||||
// Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii.
|
||||
let p = lu.x * lu.x + lw.x * lw.x;
|
||||
let q = lu.y * lu.y + lw.y * lw.y;
|
||||
let r = lu.x * lu.y + lw.x * lw.y;
|
||||
let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt();
|
||||
let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt();
|
||||
let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt();
|
||||
let (axis, len, other) = if disc <= 1e-12 * (p + q) {
|
||||
// A circle: every direction is an axis, so keep the mapped vector's.
|
||||
(lu / lu.hypot().max(1e-300), s1, s2)
|
||||
} else {
|
||||
let th = 0.5 * (2.0 * r).atan2(p - q);
|
||||
let e1 = Vec2::new(th.cos(), th.sin());
|
||||
let e2 = perp(e1);
|
||||
if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) }
|
||||
};
|
||||
let axis = if axis.dot(lu) < 0.0 { -axis } else { axis };
|
||||
self.start = start;
|
||||
self.end = start + axis * len;
|
||||
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
|
||||
}
|
||||
|
||||
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
|
||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||
let u = self.end - self.start;
|
||||
let l2 = u.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return 0.0;
|
||||
}
|
||||
let d = p - self.start;
|
||||
let along = d.dot(u) / l2;
|
||||
match kind {
|
||||
GradientKind::Radial => along.hypot(d.cross(u) / (l2 * self.aspect.max(1e-9))),
|
||||
_ => along,
|
||||
}
|
||||
}
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
let v = self.end - self.start;
|
||||
@@ -183,6 +239,19 @@ impl GradientPaint {
|
||||
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
|
||||
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
|
||||
}
|
||||
/// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms
|
||||
/// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps).
|
||||
pub fn pin(&mut self, bounds: Rect) {
|
||||
if self.geom.is_none() {
|
||||
self.geom = Some(self.resolve(bounds));
|
||||
}
|
||||
}
|
||||
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
if let Some(g) = &mut self.geom {
|
||||
g.transform(a, self.gradient.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -225,4 +294,77 @@ mod tests {
|
||||
assert_eq!(g.stops[0].color.to_hex(), "#000000");
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
|
||||
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_90_gives_vertical_vector() {
|
||||
let mut g = linear((0.0, 0.0), (100.0, 0.0));
|
||||
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||||
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
|
||||
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_swaps_start_and_end() {
|
||||
let mut g = linear((0.0, 25.0), (100.0, 25.0));
|
||||
// Reflect across the vertical axis through x = 50.
|
||||
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
|
||||
g.transform(flip, GradientKind::Linear);
|
||||
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
|
||||
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0 };
|
||||
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
|
||||
assert!((g.aspect - 0.5).abs() < 1e-9);
|
||||
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
|
||||
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0 };
|
||||
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
|
||||
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_follow_shear_and_rotation() {
|
||||
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6 };
|
||||
let mut m = g;
|
||||
m.transform(shear, kind);
|
||||
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
|
||||
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
|
||||
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
|
||||
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
|
||||
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
|
||||
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5 };
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
|
||||
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_fixes_the_fit_once() {
|
||||
let mut p = GradientPaint::new(Gradient::default());
|
||||
p.angle = 90.0;
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
p.pin(b);
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
|
||||
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,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)]
|
||||
|
||||
+41
-14
@@ -448,13 +448,17 @@ impl Node {
|
||||
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
|
||||
/// `scale_strokes` also scales stroke weights by the transform's mean scale.
|
||||
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
self.pin_gradients();
|
||||
}
|
||||
if scale_strokes {
|
||||
let det = a.determinant().abs().sqrt();
|
||||
if (det - 1.0).abs() > 1e-9 {
|
||||
self.appearance.scale_strokes(det);
|
||||
}
|
||||
}
|
||||
transform_paints(&mut self.appearance, a);
|
||||
self.appearance.transform_gradients(a);
|
||||
match &mut self.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
@@ -516,24 +520,24 @@ impl Node {
|
||||
self.walk(&mut |_| n += 1);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_paints(ap: &mut Appearance, a: Affine) {
|
||||
use crate::appearance::AppearanceItem;
|
||||
use vectorcraft_color::Paint;
|
||||
for it in &mut ap.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p
|
||||
&& let Some(geom) = &mut g.geom
|
||||
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
|
||||
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
|
||||
pub fn pin_gradients(&mut self) {
|
||||
if self.appearance.has_unplaced_gradient()
|
||||
&& let Some(b) = self.geometric_bounds()
|
||||
{
|
||||
geom.transform(a);
|
||||
self.appearance.pin_gradients(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
|
||||
fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-12 * m0.abs().max(1.0);
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -555,6 +559,29 @@ mod tests {
|
||||
assert_eq!(child.appearance.stroke_width(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
|
||||
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
|
||||
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut n = fresh();
|
||||
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);
|
||||
assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting");
|
||||
let mut n = fresh();
|
||||
n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
|
||||
let (f, s) = geoms(&n);
|
||||
// Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical.
|
||||
let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
|
||||
want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
|
||||
let f = f.unwrap();
|
||||
assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}");
|
||||
assert!((s.unwrap().length() - 102.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names() {
|
||||
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -131,3 +131,46 @@ fn geometry_and_aspect_in_one_call() {
|
||||
s.execute("appearance.setItem", &q).unwrap();
|
||||
assert_eq!(fill_gradient(&s, id).gradient, g.gradient);
|
||||
}
|
||||
|
||||
// ---------- M3.15: gradients follow every transform ----------
|
||||
|
||||
fn fill_geom(s: &Session, id: NodeId) -> GradientGeom {
|
||||
fill_gradient(s, id).geom.expect("placed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_reflect_and_scale_carry_unplaced_gradients() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
assert!(fill_gradient(&s, id).geom.is_none(), "a move keeps refitting");
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
let g = fill_geom(&s, id);
|
||||
assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}");
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
let r = fill_geom(&s, id);
|
||||
// The vertical vector is centred on the object, so the reflection swaps its ends.
|
||||
assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}");
|
||||
|
||||
// A circle's radial gradient becomes an ellipse under a non-uniform scale.
|
||||
let c = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
|
||||
let c = NodeId(c["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let e = fill_geom(&s, c);
|
||||
assert!((e.aspect - 0.5).abs() < 1e-9 && (e.length() - 100.0).abs() < 1e-9, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_distort_maps_gradients_once() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||||
// An affine distort (a sheared parallelogram) maps the gradient exactly like object.transform.
|
||||
s.execute("object.distort", &json!({"corners": [[20, 0], [120, 0], [100, 100], [0, 100]]})).unwrap();
|
||||
let g = fill_geom(&s, id);
|
||||
let mut want = GradientGeom::fit(GradientKind::Linear, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
|
||||
want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear);
|
||||
assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}");
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ impl Writer<'_> {
|
||||
|
||||
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let mut geom = g.resolve(bounds);
|
||||
geom.transform(self.xf);
|
||||
geom.transform(self.xf, g.gradient.kind);
|
||||
let (s, e) = (geom.start, geom.end);
|
||||
let head = match g.gradient.kind {
|
||||
GradientKind::Radial => {
|
||||
|
||||
@@ -70,11 +70,6 @@ fn aff(t: usvg::Transform) -> Affine {
|
||||
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
|
||||
}
|
||||
|
||||
/// Linear part of `m` applied to `v`.
|
||||
fn lin(m: Affine, v: Vec2) -> Vec2 {
|
||||
(m * v.to_point()) - (m * Point::ZERO)
|
||||
}
|
||||
|
||||
fn is_plain(g: &usvg::Group) -> bool {
|
||||
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
|
||||
}
|
||||
@@ -277,17 +272,10 @@ impl Importer {
|
||||
if lg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id()));
|
||||
}
|
||||
let t = m * aff(lg.transform());
|
||||
let (p1, p2) = (Point::new(lg.x1() as f64, lg.y1() as f64), Point::new(lg.x2() as f64, lg.y2() as f64));
|
||||
let start = t * p1;
|
||||
let far = t * p2;
|
||||
// Isolines (perpendicular to the vector in gradient space) stay parallel under an
|
||||
// affine map; the document gradient vector is the normal to their image.
|
||||
let dir = p2 - p1;
|
||||
let iso = lin(t, Vec2::new(-dir.y, dir.x));
|
||||
let nrm = Vec2::new(-iso.y, iso.x);
|
||||
let end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
|
||||
gp(GradientKind::Linear, stops(lg), GradientGeom { start, end, aspect: 1.0 })
|
||||
let mut geom =
|
||||
GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0 };
|
||||
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
|
||||
gp(GradientKind::Linear, stops(lg), geom)
|
||||
}
|
||||
usvg::Paint::RadialGradient(rg) => {
|
||||
if rg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
@@ -296,14 +284,10 @@ impl Importer {
|
||||
if (rg.fx() - rg.cx()).abs() > 1e-4 || (rg.fy() - rg.cy()).abs() > 1e-4 {
|
||||
self.warn(format!("gradient '{}': focal point ignored", rg.id()));
|
||||
}
|
||||
let t = m * aff(rg.transform());
|
||||
let r = rg.r().get() as f64;
|
||||
let c = Point::new(rg.cx() as f64, rg.cy() as f64);
|
||||
let vx = lin(t, Vec2::new(r, 0.0));
|
||||
let vy = lin(t, Vec2::new(0.0, r));
|
||||
let start = t * c;
|
||||
let aspect = if vx.hypot() > 1e-12 { vy.hypot() / vx.hypot() } else { 1.0 };
|
||||
gp(GradientKind::Radial, stops(rg), GradientGeom { start, end: start + vx, aspect })
|
||||
let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0 };
|
||||
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
|
||||
gp(GradientKind::Radial, stops(rg), geom)
|
||||
}
|
||||
usvg::Paint::Pattern(pt) => {
|
||||
self.warn(format!("pattern '{}' not supported; painted as none", pt.id()));
|
||||
|
||||
+26
-24
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
@@ -98,4 +95,17 @@ mod tests {
|
||||
assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff");
|
||||
assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_sample_honours_aspect() {
|
||||
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient {
|
||||
kind: vectorcraft_color::GradientKind::Radial,
|
||||
..Default::default()
|
||||
});
|
||||
// Radius 100 along x, 50 across: 50 pt above the centre is already the outer (black) edge.
|
||||
g.geom = Some(vectorcraft_color::GradientGeom { start: Point::ZERO, end: Point::new(100.0, 0.0), aspect: 0.5 });
|
||||
let paint = Paint::Gradient(Box::new(g));
|
||||
assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000");
|
||||
assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user