Merge pull request #6961 from dotCooCoo/fix/gradient-repeat-runs

Gradients: a repeated gradient with many stops keeps every run
This commit is contained in:
BFlat
2026-11-13 05:22:59 +07:00
committed by GitHub
12 changed files with 560 additions and 52 deletions
+58 -12
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
expand
),
@@ -194,10 +194,12 @@ impl Expandable {
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
_ => shape && !c.appearance.stroke_paint().is_none(),
};
e.gradient |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
};
// Once one is found, the next path is not measured.
e.gradient = e.gradient
|| match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
_ => shape && gradient_expands(d, c),
};
e.fill |= e.gradient;
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
@@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
}
}
/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or
/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a
/// path that live-effect plug-ins paint, whether a piece of the art they paint has one
/// ([`expand_pieces`]).
fn gradient_expands(d: &Document, l: &Node) -> bool {
if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
return false;
}
if !vectorcraft_render::effects::has_painted_effects(l) {
return piece_gradient_expands(d, l);
}
let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false };
let mut any = false;
art.walk(&mut |c| {
any = any
|| (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& !c.is_ruler_guide()
&& c.appearance.items.iter().any(|i| gradient_fill(i).is_some())
&& piece_gradient_expands(d, c));
});
any
}
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
return l.appearance.items.iter().any(|i| gradient_fill(i).is_some());
}
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let Some((path, _)) = node_path(l) else { return false };
let Some(bounds) = path.bounds() else { return false };
l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| {
fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some()))
})
}
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
match i {
@@ -476,8 +515,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
// A gradient that repeats or reflects expands as its stops repeated over the box.
let (g, runs) = padded_on(g, bounds);
// A gradient that repeats or reflects expands as its stops repeated over the box (and
// stays a gradient when the box needs more runs than are written).
let (g, runs) = padded_on(g, bounds)?;
let g = &*g;
let frame = Frame::new(g, bounds);
match to {
@@ -1244,26 +1284,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32))
}
/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and
/// how many runs lie evenly from there to 1.
type Runs = Option<(f64, usize)>;
/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond
/// gradient that repeats or reflects with its stops repeated as many times as the box needs
/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the
/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated
/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from
/// there to 1.
fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) {
/// there to 1. `None` when the box needs more runs than are written
/// ([`vectorcraft_color::Padded::capped`]): the gradient stays.
fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> {
use std::borrow::Cow;
if !g.gradient.is_spread() {
return (Cow::Borrowed(g), None);
return Some((Cow::Borrowed(g), None));
}
let geom = g.resolve(bounds);
match g.gradient.padded_over(&geom, bounds) {
Some(p) if p.capped => None,
Some(p) => {
let runs = Some((p.first, p.runs));
(Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)
Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs))
}
None => {
let runs = Some((0.0, g.gradient.repeat_count() as usize));
(Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)
Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs))
}
}
}