Merge pull request #7586 from dotCooCoo/fix/blend-spot-stop-plates

Blend: steps between gradients linked to the same spot swatch keep its process plates
This commit is contained in:
BFlat
2026-11-14 21:54:47 +07:00
committed by GitHub
3 changed files with 173 additions and 15 deletions
+142 -15
View File
@@ -45,7 +45,24 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
x + (y - x) * t
}
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
/// The process plates between two stops linked to one spot swatch: each of C, M, Y and K in turn,
/// a stop without plates counting as none of each. The ends keep their own plates.
fn lerp_plates(a: Option<[f32; 4]>, b: Option<[f32; 4]>, t: f32) -> Option<[f32; 4]> {
if t <= 0.0 {
return a;
}
if t >= 1.0 {
return b;
}
if a.is_none() && b.is_none() {
return None;
}
let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default());
Some(std::array::from_fn(|i| lerp_f32(a[i], b[i], t)))
}
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`), with no
/// process plates (a solid has none).
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
@@ -53,16 +70,18 @@ fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) ->
s.opacity = 1.0;
s.swatch = swatch.map(str::to_string);
s.tint = tint;
s.plates = None;
}
g
}
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
/// The swatch, tint and process plates `g` paints at `t`: a stop's own link, or the shared swatch
/// of the two stops around `t` at the tint and plates between theirs (unlinked when they differ),
/// as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32, Option<[f32; 4]>) {
let stops = &g.stops;
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
let Some(first) = stops.first() else { return (None, 1.0) };
let link = |s: &GradientStop| (s.swatch.clone(), s.tint, s.plates);
let Some(first) = stops.first() else { return (None, 1.0, None) };
if t <= first.offset {
return link(first);
}
@@ -74,14 +93,14 @@ fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return match (&a.swatch, &b.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
_ if v <= 0.0 => link(a),
_ if v >= 1.0 => link(b),
_ => (None, 1.0),
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v), lerp_plates(a.plates, b.plates, v)),
_ => (None, 1.0, None),
};
}
}
stops.last().map_or((None, 1.0), link)
stops.last().map_or((None, 1.0, None), link)
}
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
@@ -91,8 +110,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
.iter()
.map(|&o| {
let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
let (swatch, tint, plates) = link_at(g, o);
GradientStop { opacity, swatch, tint, plates, ..GradientStop::new(o, color) }
})
.collect();
out
@@ -139,16 +158,18 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
.iter()
.zip(&gb.stops)
.map(|(sa, sb)| {
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
_ => (None, 1.0),
// Stops on two tints of one swatch stay linked to it, as solids do (#3234), and keep
// the process plates printed with it.
let (swatch, tint, plates) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t), lerp_plates(sa.plates, sb.plates, t)),
_ => (None, 1.0, None),
};
GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
swatch,
tint,
plates,
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
}
})
@@ -2462,6 +2483,112 @@ mod tests {
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
/// A stop linked to the spot swatch "Spot" at `tint`, printed with process `plates`.
fn spot_stop(offset: f32, tint: f32, plates: Option<[f32; 4]>) -> GradientStop {
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
stop.plates = plates;
stop
}
fn gradient_paint(stops: Vec<GradientStop>) -> Paint {
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
}
fn assert_plates(got: Option<[f32; 4]>, want: [f32; 4]) {
let got = got.unwrap_or_else(|| panic!("plates {want:?}, not none"));
assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
}
#[test]
fn blend_gradient_spot_process_plates_interpolate() {
let (p, q) = ([0.2, 0.4, 0.0, 0.0], [0.6, 0.0, 0.8, 1.0]);
let a = gradient_paint(vec![spot_stop(0.0, 0.2, Some(p)), spot_stop(1.0, 0.2, Some(p))]);
let b = gradient_paint(vec![spot_stop(0.0, 1.0, Some(q)), spot_stop(1.0, 1.0, Some(q))]);
let Paint::Gradient(m) = lerp_paint(&a, &b, 0.25) else { panic!() };
for s in &m.gradient.stops {
assert_eq!(s.swatch.as_deref(), Some("Spot"));
assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint);
assert_plates(s.plates, [0.3, 0.3, 0.2, 0.25]);
}
// Without plates on either side the steps print the spot ink alone, as before.
let bare = |tint| gradient_paint(vec![spot_stop(0.0, tint, None), spot_stop(1.0, tint, None)]);
let Paint::Gradient(m) = lerp_paint(&bare(0.2), &bare(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn resampled_gradient_spot_process_plates_interpolate() {
let (p, q, r) = ([0.0, 0.4, 0.0, 0.0], [0.6, 0.4, 0.3, 0.0], [0.2, 0.2, 0.2, 0.2]);
let mut first = spot_stop(0.0, 0.2, Some(p));
first.midpoint = 0.25;
let two = Gradient { stops: vec![first, spot_stop(1.0, 0.8, Some(q))], ..Gradient::default() };
let three =
Gradient { stops: vec![spot_stop(0.0, 1.0, Some(r)), spot_stop(0.5, 1.0, Some(r)), spot_stop(1.0, 1.0, Some(r))], ..Gradient::default() };
let Some((ra, rb)) = common_stops(&two, &three) else { panic!() };
let offsets: Vec<f32> = ra.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.25, 0.5, 1.0]);
// The original stops keep their plates; between them, the midpoint at 0.25 puts 0.5 two
// thirds of the way along the inks.
assert_eq!((ra.stops[0].plates, ra.stops[3].plates), (Some(p), Some(q)));
assert_plates(ra.stops[1].plates, [0.3, 0.4, 0.15, 0.0]);
assert_plates(ra.stops[2].plates, [0.4, 0.4, 0.2, 0.0]);
assert!((ra.stops[2].tint - 0.6).abs() < 1e-5, "{}", ra.stops[2].tint);
assert!(rb.stops.iter().all(|s| s.plates == Some(r)), "{:?}", rb.stops);
let Paint::Gradient(m) = lerp_paint(&gradient_paint(two.stops), &gradient_paint(three.stops), 0.5) else { panic!() };
assert_eq!(m.gradient.stops.len(), 4);
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
assert_plates(m.gradient.stops[2].plates, [0.3, 0.3, 0.2, 0.1]);
}
#[test]
fn blend_gradient_missing_plates_mean_zero() {
let p = [0.4, 0.8, 0.0, 0.2];
let with = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]);
let without = gradient_paint(vec![spot_stop(0.0, 1.0, None), spot_stop(1.0, 1.0, None)]);
let Paint::Gradient(m) = lerp_paint(&without, &with, 0.25) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
m.gradient.stops.iter().for_each(|s| assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05]));
// The ends keep their own plates.
let Paint::Gradient(m) = lerp_paint(&without, &with, 0.0) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops);
// Another swatch, or an unlinked stop: process steps with no plates, as before.
let mut other = spot_stop(0.0, 1.0, Some(p));
other.swatch = Some("Other".into());
let other = gradient_paint(vec![other.clone(), GradientStop { offset: 1.0, ..other }]);
let mut plain = spot_stop(0.0, 1.0, None);
plain.swatch = None;
let plain = gradient_paint(vec![plain.clone(), GradientStop { offset: 1.0, ..plain }]);
for b in [&other, &plain] {
let Paint::Gradient(m) = lerp_paint(&with, b, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops);
}
}
#[test]
fn blend_solid_to_spot_gradient_does_not_copy_plates() {
let p = [0.4, 0.8, 0.0, 0.2];
let gradient = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]);
let Paint::Gradient(g) = &gradient else { panic!() };
assert!(solid_gradient(Color::BLACK, Some("Spot"), 0.2, &g.gradient).stops.iter().all(|s| s.plates.is_none()));
let solid = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(0.2), swatch: Some("Spot".into()), tint: 0.2 };
// The solid's end has no plates: they grow from none toward the gradient's, both ways round.
for (a, b, t) in [(&solid, &gradient, 0.25), (&gradient, &solid, 0.75)] {
let Paint::Gradient(m) = lerp_paint(a, b, t) else { panic!() };
for s in &m.gradient.stops {
assert_eq!(s.swatch.as_deref(), Some("Spot"));
assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint);
assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05]);
}
}
// A process solid unlinks the steps, and they take no plates.
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &gradient, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn gradients_with_different_stop_counts_interpolate() {
let g = |stops: &[(f32, Color)]| {
+30
View File
@@ -161,6 +161,36 @@ fn spot_alternates_and_device_n_gradient_inks_are_written_as_the_document_holds_
assert!(text.contains("/C0[0 0 0.75]/C1[0.75 0.04 0]"), "the stops' C75, K4 and 75% Gold are written as they are");
}
#[test]
fn blend_steps_between_spot_gradients_with_process_plates_print_both_inks() {
let stop = |offset: f32, tint: f32, plates: Option<[f32; 4]>| {
let mut s = GradientStop::new(offset, Color::cmyk(0.0, 0.2, 0.8, 0.1).tinted(tint));
s.swatch = Some("Gold".into());
s.tint = tint;
s.plates = plates;
s
};
let gradient = |tint: f32, plates: Option<[f32; 4]>| {
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop(0.0, tint, plates), stop(1.0, tint, plates)]))))
};
let step_pdf = |a: &Paint, b: &Paint| {
let mut d = doc(ColorMode::Cmyk, &[]);
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true });
let step = vectorcraft_doc::blend::lerp_paint(a, b, 0.5);
let l = d.layers[0].id;
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(step, Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned()
};
// The middle step prints 60% Gold over 30% Cyan, in one DeviceN shading of both inks.
let text = step_pdf(&gradient(0.2, Some([0.2, 0.0, 0.0, 0.0])), &gradient(1.0, Some([0.4, 0.0, 0.0, 0.0])));
assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}");
assert!(text.contains("/C0[0.3 0.6]/C1[0.3 0.6]"), "{text}");
// Without plates the step is a Separation shading of Gold alone.
let text = step_pdf(&gradient(0.2, None), &gradient(1.0, None));
assert!(!text.contains("/DeviceN") && text.contains("[/Separation/Gold/"), "{text}");
}
/// A spot swatch's Separation space with alternate C0 M20 Y80 K10 under `colorant` (a PDF name).
fn gold_separation(colorant: &str) -> String {
format!("[/Separation{colorant}/DeviceCMYK<</FunctionType 2/Domain[0 1]/Range[0 1 0 1 0 1 0 1]/C0[0 0 0 0]/C1[0 0.2 0.8 0.1]/N 1>>]")
+1
View File
@@ -1141,6 +1141,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
- **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`).
- **Blend steps of spot gradients with process plates:** when the matching stops of two gradients, or of a gradient and a solid, are tints of one spot swatch, each step's stop stays linked to it at a tint between the two and keeps the process inks printed with it, each of C, M, Y and K between the two stops' amounts; a stop or solid without process inks counts as none of each. Gradients with different stop counts keep the inks at every resampled stop. PDF export writes such a step as one DeviceN shading of the spot and process inks. Tints of one spot color blend into tints of it, as [the help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; the process-ink interpolation is VectorCraft's own rule (`crates/doc/src/blend.rs`, `blend_gradient_spot_process_plates_interpolate`, `resampled_gradient_spot_process_plates_interpolate`, `blend_gradient_missing_plates_mean_zero`, `blend_solid_to_spot_gradient_does_not_copy_plates`; `crates/pdf/src/tests/output.rs`, `blend_steps_between_spot_gradients_with_process_plates_print_both_inks`).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are.