mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Shape gradients: honour midpoints, opacity stops, offset, dither and align (#1321)
vector::Paint::from_fill discarded every gradient field beyond stops,
angle, scale and style, so Photoshop files whose shape fills use opacity
stops, colour midpoints, a centre offset (Ofst), dither or no
"Align with layer" rendered with the wrong ramp, alpha, position or
banding. Paint::Gradient now carries the full ramp (sorted stops,
midpoints, opacity stops, dither) and the compositor's geometry
(centre offset, whole-pixel end-point snapping, pixel-corner sampling,
the align frame choice: shape bounds or canvas), with shape strokes
painting through the same path.
The vector crate cannot depend on compose, so the ramp and geometry
mirror effects::gradient_units/gradient_t, fill_layout::gradient_layout
and gradient_fill::{Ramp, midpoint_remap, dither}. A parity test in
compose (which sees both) renders each non-default field, style and
frame as a shape layer and as a fill layer and requires equal pixels;
from_fill binds every Fill::Gradient field — no ".." — so a new field
fails to compile until the shape renderer honours it.
This commit is contained in:
1 parent
3dd4fd1574
commit
d438f84225
3 files changed
+339
-51
No files matched your search
@@ -1240,6 +1240,61 @@ fn small_gradient_fill_matches_photoshop_at_all_depths() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape layer's gradient fill must render the same pixels as the compositor's gradient
|
||||
/// fill layers, for every non-default gradient field (midpoints, opacity stops, centre offset,
|
||||
/// dither, unsorted stops) and all five styles, in both frames: "Align with layer" on lays the
|
||||
/// gradient out over the shape's bounds, off over the canvas. `photocraft-vector` cannot
|
||||
/// depend on this crate, so it carries a copy of this ramp and geometry — this test is what
|
||||
/// keeps the two in step (only interior pixels, where the shape's coverage is exactly 1).
|
||||
#[test]
|
||||
fn shape_layer_gradients_match_fill_layers() {
|
||||
use photocraft_doc::GradientStyle;
|
||||
let canvas = Rect::new(0, 0, 64, 48);
|
||||
// rect(x, y, w, h): the shape spans (16, 8)–(64, 48), so its whole-pixel bounds sit strictly
|
||||
// inside the canvas — "Align with layer" then picks a different frame than the canvas.
|
||||
let path = photocraft_vector::shapes::rect(16.0, 8.0, 48.0, 40.0);
|
||||
let layer_frame = Rect::new(16, 8, 64, 48);
|
||||
let grad = |style: GradientStyle, midpoints: Vec<f32>, opacity_stops: Vec<(f32, f32)>, offset: (f32, f32), dither: bool, unsorted: bool, align: bool| {
|
||||
let mut stops = vec![(0.0, Color::BLACK), (0.5, Color::rgb(1.0, 0.25, 0.5)), (1.0, Color::WHITE)];
|
||||
if unsorted {
|
||||
stops.reverse();
|
||||
}
|
||||
Fill::Gradient { stops, angle: 30.0, scale: 1.7, style, reverse: false, opacity_stops, midpoints, offset, dither, align }
|
||||
};
|
||||
let styles = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
|
||||
// (name, midpoints, opacity stops, centre offset, dither, unsorted stops) per case.
|
||||
type Case = (&'static str, Vec<f32>, Vec<(f32, f32)>, (f32, f32), bool, bool);
|
||||
let cases: Vec<Case> = vec![
|
||||
("plain", vec![], vec![], (0.0, 0.0), false, false),
|
||||
("midpoints", vec![0.25, 0.9], vec![], (0.0, 0.0), false, false),
|
||||
("opacity stops", vec![], vec![(0.0, 1.0), (0.5, 0.2), (1.0, 0.9)], (0.0, 0.0), false, false),
|
||||
("offset", vec![], vec![], (0.3, -0.2), false, false),
|
||||
("dither", vec![], vec![], (0.0, 0.0), true, false),
|
||||
("all of them", vec![0.75], vec![(0.0, 1.0), (1.0, 0.4)], (0.1, 0.1), true, true),
|
||||
];
|
||||
for style in styles {
|
||||
for (name, mids, opac, offset, dither, unsorted) in &cases {
|
||||
for align in [true, false] {
|
||||
let f = grad(style, mids.clone(), opac.clone(), *offset, *dither, *unsorted, align);
|
||||
let frame = if align { layer_frame } else { canvas };
|
||||
let want = gradient_fill::render(&f, canvas, frame);
|
||||
let sh = photocraft_doc::vector::ShapeLayer { path: path.clone(), fill: Some(f), ..Default::default() };
|
||||
let s = photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, canvas);
|
||||
let mut worst = 0.0f32;
|
||||
for y in 9..47 {
|
||||
for x in 17..63 {
|
||||
let p = want[(y as usize) * canvas.width() as usize + x as usize];
|
||||
for (got, want_ch) in (0..4usize).map(|ch| (s.sample_channel(x, y, ch), p[ch])) {
|
||||
worst = worst.max((got - want_ch).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= 1.5 / 255.0, "{style:?} {name} align={align}: worst delta {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall document with soft content, a translucent region and an adjustment.
|
||||
fn tall_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
|
||||
+193
-49
@@ -209,33 +209,57 @@ pub enum Paint {
|
||||
/// Straight RGBA.
|
||||
Solid([f32; 4]),
|
||||
Gradient {
|
||||
/// Colour stops sorted by location (straight RGBA).
|
||||
stops: Vec<(f32, [f32; 4])>,
|
||||
/// Colour midpoint per segment between consecutive sorted stops (missing = 0.5).
|
||||
midpoints: Vec<f32>,
|
||||
/// Opacity stops `(location, opacity)` sorted by location; empty = each stop's alpha.
|
||||
opacity_stops: Vec<(f32, f32)>,
|
||||
style: GradientStyle,
|
||||
angle: f32,
|
||||
scale: f32,
|
||||
/// Effective `(angle, scale, offset)` after whole-pixel snapping ([`gradient_layout`]).
|
||||
layout: (f32, f32, (f32, f32)),
|
||||
reverse: bool,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
dither: bool,
|
||||
/// Layout frame (`x0, y0, x1, y1`): the shape's bounds, or the canvas when the fill
|
||||
/// does not align with the layer.
|
||||
frame: (f64, f64, f64, f64),
|
||||
},
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
/// Paint for a model fill, with gradients laid out over `bounds` (`x0, y0, x1, y1`).
|
||||
/// Patterns are not rendered yet (transparent), matching the compositor.
|
||||
pub fn from_fill(f: &Fill, bounds: (f64, f64, f64, f64)) -> Paint {
|
||||
/// Paint for a model fill, with gradients laid out over `bounds` (`x0, y0, x1, y1`) — the
|
||||
/// layer's frame — or over `canvas` when the fill is not aligned with the layer. Every
|
||||
/// gradient field is honoured (midpoints, opacity stops, centre offset, dither, unsorted
|
||||
/// stops), rendering the same pixels as the compositor's fill layers; the parity tests in
|
||||
/// `photocraft-compose` (which this crate cannot depend on) keep the two in step. Pattern
|
||||
/// fills are not rendered (transparent): they need the document's pattern table, which
|
||||
/// lives above this crate.
|
||||
pub fn from_fill(f: &Fill, bounds: (f64, f64, f64, f64), canvas: (f64, f64, f64, f64)) -> Paint {
|
||||
let rgba = |c: &photocraft_color::Color| {
|
||||
let v = c.to_rgb();
|
||||
[v[0], v[1], v[2], c.alpha]
|
||||
};
|
||||
match f {
|
||||
Fill::Solid(c) => Paint::Solid(rgba(c)),
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, .. } => Paint::Gradient {
|
||||
stops: stops.iter().map(|(p, c)| (*p, rgba(c))).collect(),
|
||||
style: *style,
|
||||
angle: *angle,
|
||||
scale: *scale,
|
||||
reverse: *reverse,
|
||||
bounds,
|
||||
},
|
||||
// No `..` here on purpose: a new gradient field must fail to compile until the
|
||||
// shape renderer honours it (the same sentinel the document/compositor pair uses).
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => {
|
||||
let frame = if *align { bounds } else { canvas };
|
||||
let mut stops: Vec<(f32, [f32; 4])> = stops.iter().map(|(p, c)| (*p, rgba(c))).collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity = opacity_stops.clone();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Paint::Gradient {
|
||||
stops,
|
||||
midpoints: midpoints.clone(),
|
||||
opacity_stops: opacity,
|
||||
style: *style,
|
||||
layout: gradient_layout(*style, *angle, *scale, *offset, frame),
|
||||
reverse: *reverse,
|
||||
dither: *dither,
|
||||
frame,
|
||||
}
|
||||
}
|
||||
Fill::Pattern { .. } => Paint::None,
|
||||
}
|
||||
}
|
||||
@@ -245,53 +269,168 @@ impl Paint {
|
||||
match self {
|
||||
Paint::None => [0.0; 4],
|
||||
Paint::Solid(c) => *c,
|
||||
Paint::Gradient { stops, style, angle, scale, reverse, bounds } => {
|
||||
let t = gradient_t(*style, *angle, *scale, *reverse, *bounds, x, y);
|
||||
sample_stops(stops, t)
|
||||
Paint::Gradient { stops, midpoints, opacity_stops, style, layout, reverse, dither: dith, frame } => {
|
||||
let t = gradient_t(*style, layout.0, layout.1, *reverse, layout.2, *frame, x, y);
|
||||
let mut c = sample_stops(stops, midpoints, opacity_stops, t);
|
||||
if *dith {
|
||||
dither(&mut c, x.floor() as i32, y.floor() as i32);
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient parameter at `(x, y)`: the gradient spans the bounds' extent along its angle
|
||||
/// (degrees, counter-clockwise from 3 o'clock), centred on the bounds.
|
||||
fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, b: (f64, f64, f64, f64), x: f64, y: f64) -> f32 {
|
||||
let (w, h) = ((b.2 - b.0).max(1.0), (b.3 - b.1).max(1.0));
|
||||
let (cx, cy) = (b.0 + w / 2.0, b.1 + h / 2.0);
|
||||
/// `(chord, diagonal)` gradient lengths over a `w × h` frame at `angle` (radians): Linear and
|
||||
/// Reflected gradients span the chord, the other styles the half-diagonal. Mirrors the
|
||||
/// compositor's `effects::gradient_units` (fitted against psd-tools shape-fx2, layer_effects).
|
||||
fn gradient_units(angle: f64, w: f64, h: f64) -> (f64, f64) {
|
||||
let (s, c) = angle.sin_cos();
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0);
|
||||
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0);
|
||||
(chord, len)
|
||||
}
|
||||
|
||||
/// Effective `(angle, scale, offset)` for a gradient laid out in `frame`, snapping Linear and
|
||||
/// Reflected end points to whole pixels as Photoshop does: on small frames that changes the
|
||||
/// effective angle. Mirrors the compositor's `fill_layout::gradient_layout`, which the GPU
|
||||
/// compositor shares.
|
||||
fn gradient_layout(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: (f64, f64, f64, f64)) -> (f32, f32, (f32, f32)) {
|
||||
let unchanged = (angle, scale, offset);
|
||||
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected)
|
||||
|| !angle.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| !offset.0.is_finite()
|
||||
|| !offset.1.is_finite()
|
||||
{
|
||||
return unchanged;
|
||||
}
|
||||
let w = (frame.2 - frame.0).max(1.0);
|
||||
let h = (frame.3 - frame.1).max(1.0);
|
||||
let (cx, cy) = (frame.0 + w / 2.0 + f64::from(offset.0) * w, frame.1 + h / 2.0 + f64::from(offset.1) * h);
|
||||
// Unscaled chord length along `a` (radians), as `gradient_t`.
|
||||
let chord_of = |a: f64| gradient_units(a, w, h).0;
|
||||
let a = f64::from(angle).to_radians();
|
||||
let half = chord_of(a) * f64::from(scale.max(1e-3)) / 2.0;
|
||||
let (s, c) = a.sin_cos();
|
||||
let (dx, dy) = (c * half, -s * half);
|
||||
// Truncate to the pixel grid (with a little slack for rounding error in exact cases).
|
||||
let snap = |v: f64| (v + 1e-4).floor();
|
||||
let end = (snap(cx + dx), snap(cy + dy));
|
||||
let (start, mid) = match style {
|
||||
GradientStyle::Reflected => ((cx, cy), (cx, cy)),
|
||||
_ => {
|
||||
let s0 = (snap(cx - dx), snap(cy - dy));
|
||||
(s0, ((s0.0 + end.0) / 2.0, (s0.1 + end.1) / 2.0))
|
||||
}
|
||||
};
|
||||
let v = (end.0 - start.0, end.1 - start.1);
|
||||
let len = v.0.hypot(v.1);
|
||||
if len < 0.5 {
|
||||
return unchanged;
|
||||
}
|
||||
let a2 = (-v.1).atan2(v.0);
|
||||
// Reflected spans half the chord from the centre; Linear the whole chord.
|
||||
let span = if style == GradientStyle::Reflected { 2.0 * len } else { len };
|
||||
let scale2 = span / chord_of(a2);
|
||||
let shift = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
|
||||
(a2.to_degrees() as f32, scale2 as f32, (offset.0 + shift.0, offset.1 + shift.1))
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` (`0..=1`) at pixel centre `(x, y)` in `frame`: the centre moves by
|
||||
/// `offset` (a fraction of the frame), Linear and Reflected gradients sample the pixel's
|
||||
/// top-left corner (Photoshop's whole-pixel end points). Mirrors the compositor's
|
||||
/// `effects::gradient_t` (which the parity tests keep in step); unlike the compositor, the
|
||||
/// frame stays exact fractional shape bounds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, offset: (f32, f32), frame: (f64, f64, f64, f64), x: f64, y: f64) -> f32 {
|
||||
let w = (frame.2 - frame.0).max(1.0);
|
||||
let h = (frame.3 - frame.1).max(1.0);
|
||||
let cx = frame.0 + w / 2.0 + f64::from(offset.0) * w;
|
||||
let cy = frame.1 + h / 2.0 + f64::from(offset.1) * h;
|
||||
let a = f64::from(angle).to_radians();
|
||||
let (s, c) = a.sin_cos();
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let along = dx * c - dy * s;
|
||||
let across = dx * s + dy * c;
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0) * f64::from(scale.max(1e-3));
|
||||
let t = match style {
|
||||
GradientStyle::Linear => along / len + 0.5,
|
||||
GradientStyle::Reflected => (along / (len / 2.0)).abs(),
|
||||
let (chord, len) = gradient_units(a, w, h);
|
||||
let (chord, len) = (chord * f64::from(scale.max(1e-3)), len * f64::from(scale.max(1e-3)));
|
||||
let corner = 0.5 * (c - s);
|
||||
let mut t = match style {
|
||||
GradientStyle::Linear => (along - corner) / chord + 0.5,
|
||||
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
|
||||
GradientStyle::Radial => dx.hypot(dy) / (len / 2.0),
|
||||
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
|
||||
// Clockwise sweep starting at the gradient angle.
|
||||
GradientStyle::Angle => ((a - (-dy).atan2(dx)) / std::f64::consts::TAU).rem_euclid(1.0),
|
||||
};
|
||||
let t = t.clamp(0.0, 1.0) as f32;
|
||||
if reverse { 1.0 - t } else { t }
|
||||
t = t.clamp(0.0, 1.0);
|
||||
(if reverse { 1.0 - t } else { t }) as f32
|
||||
}
|
||||
|
||||
fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||||
match stops {
|
||||
[] => [0.0; 4],
|
||||
[s] => s.1,
|
||||
_ => {
|
||||
if t <= stops[0].0 {
|
||||
return stops[0].1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let k = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
|
||||
return std::array::from_fn(|i| w[0].1[i] + (w[1].1[i] - w[0].1[i]) * k);
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
/// Where a segment of relative position `u` lands once its midpoint is `m` (0.5 = unchanged):
|
||||
/// the colour is half-way at `m`, linear on either side. Mirrors the compositor's
|
||||
/// `gradient_fill::midpoint_remap`.
|
||||
fn midpoint_remap(u: f32, m: f32) -> f32 {
|
||||
let m = m.clamp(0.05, 0.95);
|
||||
if (m - 0.5).abs() < 1e-6 {
|
||||
u
|
||||
} else if u <= m {
|
||||
0.5 * u / m
|
||||
} else {
|
||||
0.5 + 0.5 * (u - m) / (1.0 - m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour stops with midpoints at `t` (stops sorted by location).
|
||||
fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for (i, w) in stops.windows(2).enumerate() {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.0 {
|
||||
let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||||
let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5));
|
||||
return std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k);
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
/// Opacity stops at `t` (stops sorted by location; 1.0 when empty).
|
||||
fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return 1.0 };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
|
||||
return w[0].1 + (w[1].1 - w[0].1) * u;
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
/// Colour and alpha at `t`: colour stops with midpoints, alpha times opacity stops. Mirrors
|
||||
/// the compositor's `gradient_fill::Ramp::sample`.
|
||||
fn sample_stops(stops: &[(f32, [f32; 4])], mids: &[f32], opacity: &[(f32, f32)], t: f32) -> [f32; 4] {
|
||||
let mut c = sample_color(stops, mids, t);
|
||||
if !opacity.is_empty() {
|
||||
c[3] *= sample_opacity(opacity, t);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Adds one level of the shared dither noise to a colour's channels (as the Gradient tool and
|
||||
/// the compositor's fill layers do).
|
||||
fn dither(c: &mut [f32; 4], x: i32, y: i32) {
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c.iter_mut().take(3) {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to render a shape layer, compiled once.
|
||||
@@ -304,13 +443,16 @@ pub struct CompiledShape {
|
||||
}
|
||||
|
||||
impl CompiledShape {
|
||||
pub fn new(shape: &ShapeLayer, tol: f64) -> Self {
|
||||
/// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients
|
||||
/// without "Align with layer" are laid out in.
|
||||
pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self {
|
||||
let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||||
let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1));
|
||||
let area = fill_rasterizer(&shape.path, tol);
|
||||
let mut bounds = None;
|
||||
let fill = shape.fill.as_ref().map(|f| {
|
||||
bounds = area.pixel_bounds();
|
||||
(area.clone(), Paint::from_fill(f, pb))
|
||||
(area.clone(), Paint::from_fill(f, pb, cb))
|
||||
});
|
||||
let all_closed = shape.path.subpaths.iter().all(|s| s.closed);
|
||||
let mut align = None;
|
||||
@@ -327,7 +469,7 @@ impl CompiledShape {
|
||||
(Some(a), Some(b)) => Some(a.union(&b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
(r, Paint::from_fill(&s.paint, pb), s.opacity.clamp(0.0, 1.0))
|
||||
(r, Paint::from_fill(&s.paint, pb, cb), s.opacity.clamp(0.0, 1.0))
|
||||
});
|
||||
if shape.path.inverted && shape.fill.is_some() {
|
||||
bounds = Some(Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4));
|
||||
@@ -384,7 +526,8 @@ impl CompiledShape {
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders into a new surface of `format` over `clip` (typically the canvas).
|
||||
/// Renders into a new surface of `format` over `clip` (typically the canvas: it bounds the
|
||||
/// render and anchors gradients whose fill is not aligned with the layer).
|
||||
pub fn render(&self, format: PixelFormat, clip: Rect) -> Surface {
|
||||
let mut s = Surface::new(format);
|
||||
let Some(b) = self.bounds else { return s };
|
||||
@@ -405,9 +548,10 @@ impl CompiledShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a shape layer's appearance (fill, then stroke over it) in `format`, clipped to `clip`.
|
||||
/// Renders a shape layer's appearance (fill, then stroke over it) in `format`, clipped to
|
||||
/// `clip` — the canvas, which also lays out gradients whose fill is not aligned with the layer.
|
||||
pub fn render_shape(shape: &ShapeLayer, format: PixelFormat, clip: Rect) -> Surface {
|
||||
CompiledShape::new(shape, DEFAULT_TOLERANCE).render(format, clip)
|
||||
CompiledShape::new(shape, DEFAULT_TOLERANCE, clip).render(format, clip)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -160,7 +160,7 @@ fn closed_rect_stroke_and_alignment() {
|
||||
..Default::default()
|
||||
};
|
||||
let alpha_area = |sh: &ShapeLayer| {
|
||||
let rgba = CompiledShape::new(sh, DEFAULT_TOLERANCE).render_rgba(Rect::new(0, 0, 150, 150));
|
||||
let rgba = CompiledShape::new(sh, DEFAULT_TOLERANCE, Rect::new(0, 0, 150, 150)).render_rgba(Rect::new(0, 0, 150, 150));
|
||||
rgba.iter().map(|p| f64::from(p[3])).sum::<f64>()
|
||||
};
|
||||
assert!((alpha_area(&shape(StrokeAlign::Inside)) - (100.0f64.powi(2) - 80.0f64.powi(2))).abs() < 0.05);
|
||||
@@ -219,10 +219,99 @@ fn gradient_fill_spans_bounds() {
|
||||
fill: Some(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false)),
|
||||
..Default::default()
|
||||
};
|
||||
let rgba = CompiledShape::new(&sh, DEFAULT_TOLERANCE).render_rgba(Rect::new(0, 0, 100, 10));
|
||||
let rgba = CompiledShape::new(&sh, DEFAULT_TOLERANCE, Rect::new(0, 0, 100, 10)).render_rgba(Rect::new(0, 0, 100, 10));
|
||||
assert!(rgba[0][0] < 0.02 && rgba[99][0] > 0.98 && (rgba[50][0] - 0.505).abs() < 0.02);
|
||||
}
|
||||
|
||||
/// A gradient fill with every non-default field, rendered through `Paint`: the expectations
|
||||
/// below are hand-derived (the compositor's own rendering is checked against them by the
|
||||
/// parity tests in `photocraft-compose`).
|
||||
fn grad(fill: Fill, canvas: Rect) -> Vec<[f32; 4]> {
|
||||
let sh = ShapeLayer { path: shapes::rect(0.0, 0.0, 100.0, 10.0), fill: Some(fill), ..Default::default() };
|
||||
let c = Rect::new(0, 0, canvas.x1.min(100), canvas.y1.min(10));
|
||||
CompiledShape::new(&sh, DEFAULT_TOLERANCE, canvas).render_rgba(c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_midpoints_opacity_stops_offset_and_sorted_stops() {
|
||||
// Linear 0° over the path's 100 × 10 bounds: t is the pixel's left corner / 100, so pixel
|
||||
// x sits at t = x / 100.
|
||||
let g = |midpoints: Vec<f32>, opacity_stops: Vec<(f32, f32)>, offset: (f32, f32), stops: Vec<(f32, Color)>| Fill::Gradient {
|
||||
stops,
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: photocraft_doc::GradientStyle::Linear,
|
||||
reverse: false,
|
||||
opacity_stops,
|
||||
midpoints,
|
||||
offset,
|
||||
dither: false,
|
||||
align: true,
|
||||
};
|
||||
// Midpoint 0.25: the colour is half-way at t = 0.25 (pixel 25).
|
||||
let px = grad(g(vec![0.25], vec![], (0.0, 0.0), vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]), Rect::new(0, 0, 100, 10));
|
||||
assert!((px[25][0] - 0.5).abs() < 1e-3, "{}", px[25][0]);
|
||||
// Opacity stop ramp 1 → 0.5: alpha at t = 0.25 is 0.875.
|
||||
let px = grad(g(vec![], vec![(0.0, 1.0), (1.0, 0.5)], (0.0, 0.0), vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]), Rect::new(0, 0, 100, 10));
|
||||
assert!((px[25][3] - 0.875).abs() < 1e-3, "{}", px[25][3]);
|
||||
// Centre offset half a frame right: t = 0 moves to x = 50, so pixel 75 sits at t = 0.25.
|
||||
let px = grad(g(vec![], vec![], (0.5, 0.0), vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]), Rect::new(0, 0, 100, 10));
|
||||
assert!(px[25][0] < 0.01 && (px[75][0] - 0.25).abs() < 1e-3, "{} {}", px[25][0], px[75][0]);
|
||||
// Unsorted stops are sorted by location: black still left, white right.
|
||||
let px = grad(g(vec![], vec![], (0.0, 0.0), vec![(1.0, Color::WHITE), (0.0, Color::BLACK)]), Rect::new(0, 0, 100, 10));
|
||||
assert!(px[1][0] < 0.02 && px[98][0] > 0.97, "{} {}", px[1][0], px[98][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_without_align_with_layer_uses_the_canvas_frame() {
|
||||
let canvas = Rect::new(0, 0, 200, 10);
|
||||
let g = |align: bool| Fill::Gradient {
|
||||
stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: photocraft_doc::GradientStyle::Linear,
|
||||
reverse: false,
|
||||
opacity_stops: vec![],
|
||||
midpoints: vec![],
|
||||
offset: (0.0, 0.0),
|
||||
dither: false,
|
||||
align,
|
||||
};
|
||||
// Aligned: the 100-px path bounds are the frame, pixel 55 sits at t = 0.55.
|
||||
let px = grad(g(true), canvas);
|
||||
assert!((px[55][0] - 0.55).abs() < 1e-3, "{}", px[55][0]);
|
||||
// Not aligned: the 200-px canvas is the frame, the same pixel sits at t = 0.275.
|
||||
let px = grad(g(false), canvas);
|
||||
assert!((px[55][0] - 0.275).abs() < 1e-3, "{}", px[55][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_dither_is_deterministic_and_one_level() {
|
||||
let g = |dither: bool| Fill::Gradient {
|
||||
stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
|
||||
angle: 30.0,
|
||||
scale: 1.3,
|
||||
style: photocraft_doc::GradientStyle::Radial,
|
||||
reverse: false,
|
||||
opacity_stops: vec![],
|
||||
midpoints: vec![],
|
||||
offset: (0.1, -0.1),
|
||||
dither,
|
||||
align: true,
|
||||
};
|
||||
let a = grad(g(true), Rect::new(0, 0, 100, 10));
|
||||
let b = grad(g(true), Rect::new(0, 0, 100, 10));
|
||||
assert_eq!(a, b, "dither must be deterministic");
|
||||
let plain = grad(g(false), Rect::new(0, 0, 100, 10));
|
||||
let mut max = 0.0f32;
|
||||
let mut changed = 0;
|
||||
for (p, q) in a.iter().zip(&plain) {
|
||||
max = max.max((p[0] - q[0]).abs());
|
||||
changed += usize::from((p[0] - q[0]).abs() > 1e-6);
|
||||
}
|
||||
assert!(changed > 0 && max <= 1.0 / 255.0 + 1e-6, "changed={changed} max={max}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_surface_and_vector_mask_values() {
|
||||
let p = shapes::rect(10.0, 10.0, 20.0, 20.0);
|
||||
|
||||
Reference in new issue
Block a user