Compare commits

...
12 changed files with 461 additions and 107 deletions

No files matched your search

+122 -35
View File
@@ -5,17 +5,19 @@
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
//! straight into the backdrop with their own modes; the layer and its
//! interior effects blend with the layer's mode. Layer opacity applies to
//! the whole stack; fill opacity only to the layer's own pixels.
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
//! shade the composited result (inside and outside halves). Layer opacity
//! applies to the whole stack; fill opacity only to the layer's own pixels.
//!
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
//! lower ones. Drop shadows are knocked out only through see-through fill.
//!
//! Shapes come from the layer's alpha (after its mask). Strokes measure distances with a
//! 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); other effects
//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs.
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
//! are two box blurs (a tent).
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
@@ -242,9 +244,9 @@ fn dist_outside(s: &Map) -> Vec<f32> {
/// Distance metric for effect distance fields.
#[derive(Clone, Copy, PartialEq)]
enum Metric {
/// Exact Euclidean (spread/choke dilation, glows, bevels).
/// Exact Euclidean (precise glows, bevels).
Euclidean,
/// 5 × 5 chamfer (strokes; see [`chamfer_nearest`]).
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
Chamfer,
}
@@ -326,12 +328,16 @@ fn blur(m: &mut Map, size: f32) {
}
}
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge.
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. Distances use
/// the 5 × 5 chamfer metric, as for strokes: Photoshop's spread of a group drop shadow around an
/// ellipse (ag-psd group-drop-shadows) is round at 0° and 45° and falls short in between, peaking
/// near 15° where the chamfer metric overestimates most (5/255 lighter with an exact Euclidean
/// dilation, within 2/255 with the chamfer).
fn dilate(s: &Map, r: f32) -> Map {
if r <= 0.0 {
return s.clone();
}
let d = dist_outside(s);
let d = dist_outside_by(s, Metric::Chamfer);
let mut out = s.clone();
for (o, d) in out.v.iter_mut().zip(d) {
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
@@ -432,9 +438,13 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
// span the width / height.
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0) * scale.max(1e-3);
// Linear / Reflected sample the pixel's top-left corner, half a pixel before its centre
// (with the whole-pixel end points of `fill_layout`: layer_effects' overlay 5.8 → 1.8/255,
// shape-fx2 2.8 → 1.1/255, gradient-fill.psd 1.9 → 0.5/255).
let corner = 0.5 * (c - s);
let mut t = match style {
GradientStyle::Linear => along / chord + 0.5,
GradientStyle::Reflected => (along / (chord / 2.0)).abs(),
GradientStyle::Linear => (along - corner) / chord + 0.5,
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
// Clockwise sweep starting at the gradient angle.
@@ -615,13 +625,14 @@ fn paint_fx(
FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity),
FxPaint::Gradient(g) => {
let prepared = PreparedGradient::new(g);
let (angle, scale, offset) = crate::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, shape_bounds);
let w = big.width() as usize;
paint(
dst,
m,
|i| {
let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5);
prepared.sample(gradient_t(g.style, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y))
prepared.sample(gradient_t(g.style, angle, scale, g.reverse, offset, shape_bounds, x, y))
},
blend,
opacity,
@@ -811,7 +822,7 @@ pub const BEVEL_H_EPS: f32 = 1e-5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BevelGeom {
pub paint: BevelPaint,
/// Box width of the smooth height blur (size; half the size for emboss styles).
/// Box width of the smooth height blur (size; half the size, rounded up, for emboss styles).
pub width: f32,
/// Height-map gradient scale: depth × width (smooth) or depth × size (chisel), signed by
/// direction.
@@ -830,7 +841,10 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelStyle::Emboss | BevelStyle::PillowEmboss => BevelPaint::Both,
BevelStyle::InnerBevel | BevelStyle::StrokeEmboss => BevelPaint::Inner,
};
let width = if emboss { size / 2.0 } else { size };
// Emboss styles straddle the edge with half the size, rounded up to whole pixels: a 41 px
// emboss blurs over 21 (psd-tools layer_effects: 20.5 left narrow letter parts up to
// 5.7/255 dark, 21 is within 2.2).
let width = if emboss { (size / 2.0).ceil() } else { size };
let smooth = b.technique == BevelTechnique::Smooth;
let sign = if b.up { 1.0 } else { -1.0 };
BevelGeom {
@@ -922,7 +936,16 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
let (sa, ca) = angle.to_radians().sin_cos();
let (se, ce) = altitude.to_radians().sin_cos();
let light_v = [ca * ce, -sa * ce, se];
let shade_into = |depth: f32, outer: bool| -> (Map, Map) {
// Region: 0 = inside (× the shape's alpha), 1 = under the layer's edge and outside.
// A pixel is on the bevel where its height is raised. Emboss styles also shade the last pixel
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
let reach_past = g.paint == BevelPaint::Both;
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
for y in 0..shape.h as i64 {
for x in 0..shape.w as i64 {
@@ -932,7 +955,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > BEVEL_H_EPS) } else { shape.v[i] };
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
let k = shade - se;
if k > 0.0 {
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
@@ -945,13 +968,21 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
};
let maps = match g.paint {
BevelPaint::Inner | BevelPaint::Outer => {
let (hi, sh) = shade_into(g.depth, g.paint == BevelPaint::Outer);
let (hi, sh) = shade_into(g.depth, u8::from(g.paint == BevelPaint::Outer));
vec![hi, sh]
}
BevelPaint::Both => {
let (hi, sh) = shade_into(g.depth, false);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, true);
vec![hi, sh, ho, so]
// One map per colour over the whole bevel: the inside half where the shape is (× its
// alpha), the outside half (pillow: facing the other way) for the rest of each pixel.
// A 1 % edge pixel of a pillow emboss takes the outside shading (Photoshop oracle
// bevel-pillow-smooth), a 75 % one of an emboss mostly the inside (layer_effects).
let (hi, sh) = shade_into(g.depth, 0);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
let mix = |a: Map, b: Map| -> Map {
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
Map { w: a.w, h: a.h, v }
};
vec![mix(hi, ho), mix(sh, so)]
}
};
(maps, g.paint)
@@ -1196,7 +1227,7 @@ pub(crate) fn composite_with_effects_prepared(
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] != BevelPaint::Outer
&& maps.bevel_paint[i] == BevelPaint::Inner
{
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
@@ -1257,10 +1288,9 @@ pub(crate) fn composite_with_effects_prepared(
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] != BevelPaint::Inner
&& maps.bevel_paint[i] == BevelPaint::Outer
{
let k = if maps.bevel_paint[i] == BevelPaint::Both { 2 } else { 0 };
let (hi, sh) = (fx(i, k), fx(i, k + 1));
let (hi, sh) = (fx(i, 0), fx(i, 1));
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
@@ -1274,6 +1304,17 @@ pub(crate) fn composite_with_effects_prepared(
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
}
}
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Both
{
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// Layer opacity applies to the whole stack.
let op = layer.opacity;
for y in rect.y0..rect.y1 {
@@ -1305,15 +1346,15 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
/// `glow_map`, `bevel_maps` and `build_maps`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FieldKind {
/// `dist_outside` of the shape: spread of drop shadows and softer outer glows, precise outer
/// and centre glows, bevels.
/// `dist_outside` of the shape: precise outer and centre glows, bevels.
Outside,
/// `dist_inside` of the shape: precise edge inner glows, bevels.
Inside,
/// `dist_outside` of `1 - alpha`: choke of inner shadows, softer inner glows.
OutsideInverse,
/// Stroke distance outside the shape.
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
/// softer outer glows ([`dilate`]).
StrokeOutside,
/// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows.
ChokeInside,
/// Stroke distance inside the shape.
StrokeInside,
/// Stroke distance outside a shape layer's outline (from its local coverage).
@@ -1326,8 +1367,8 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize) -> V
match kind {
FieldKind::Outside => dist_outside(&s),
FieldKind::Inside => dist_inside(&s),
FieldKind::OutsideInverse => dist_outside(&s.map(|a| 1.0 - a)),
FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer),
FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer),
FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer),
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
}
@@ -1374,9 +1415,10 @@ mod tests {
let b = Rect::new(0, 0, 100, 100);
let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y);
assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01);
assert!((t(GradientStyle::Linear, 50.0, 50.0) - 0.5).abs() < 1e-6);
// Linear / reflected sample pixel corners: the centre is reached half a pixel later.
assert!((t(GradientStyle::Linear, 50.5, 50.0) - 0.5).abs() < 1e-6);
assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99);
assert!(t(GradientStyle::Reflected, 50.0, 50.0) < 1e-6);
assert!(t(GradientStyle::Reflected, 50.5, 50.0) < 1e-6);
assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6);
assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6);
assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6);
@@ -1387,6 +1429,18 @@ mod tests {
assert!(rev > 0.99);
}
#[test]
fn effect_gradients_snap_end_points_and_sample_corners() {
// 87° over 600 × 60 at (99, 422): end points (397, 482) and (400, 422), so the overlay runs
// along (3, -60) from its midpoint (398.5, 452), sampled at pixel corners.
let frame = Rect::new(99, 422, 699, 482);
let (a, sc, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 87.0, 1.0, (0.0, 0.0), frame);
assert!((a - (60f32).atan2(3.0).to_degrees()).abs() < 1e-3, "{a}");
let t = |x: f32, y: f32| gradient_t(GradientStyle::Linear, a, sc, false, o, frame, x, y);
assert!((t(399.0, 452.5) - 0.5).abs() < 1e-4, "{}", t(399.0, 452.5));
assert!(t(400.5, 422.5) > 0.99 && t(397.5, 482.5) < 0.01);
}
#[test]
fn chamfer_uses_euclidean_step_lengths() {
let (w, h) = (9, 9);
@@ -1402,6 +1456,19 @@ mod tests {
assert!(near.iter().all(|&n| n == 0));
}
#[test]
fn spread_dilates_with_the_chamfer_metric() {
// One opaque pixel grown by 3 px: at offset (1, 3) the chamfer distance is √5 + 1 = 3.236
// (Euclidean 3.162), so the anti-aliased rim keeps 3 + 0.5 − (3.236 − 0.5) = 0.764.
let mut m = Map::new(9, 9, 0.0);
m.v[4 * 9 + 4] = 1.0;
let d = dilate(&m, 3.0);
assert!((d.v[7 * 9 + 5] - 0.764).abs() < 1e-3, "{}", d.v[7 * 9 + 5]);
// (2, 3): √5 + √2 = 3.650 (Euclidean 3.606) leaves 0.350; axis steps are exact.
assert!((d.v[7 * 9 + 6] - (4.0 - 5f32.sqrt() - std::f32::consts::SQRT_2)).abs() < 1e-4, "{}", d.v[7 * 9 + 6]);
assert_eq!(d.v[4 * 9 + 7], 1.0);
}
#[test]
fn running_sum_tent_matches_the_kernel() {
let (w, h) = (37usize, 23usize);
@@ -1494,13 +1561,33 @@ mod tests {
let l = GlobalLight::default();
let (e, paint) =
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
assert_eq!((paint, e.len()), (BevelPaint::Both, 2));
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
// Emboss: the slope across the top edge faces the light on both sides.
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[2], 20, 9) > 0.3);
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[0], 20, 9) > 0.3);
let (p, _) =
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[1], 20, 9) > 0.3, "pillow: outside top edge in shadow");
}
#[test]
fn emboss_width_rounds_up_and_shades_past_the_blur() {
let mut b = bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth);
b.size = 41.0;
let g = bevel_geom(&b);
assert_eq!((g.width, g.depth), (21.0, 21.0));
b.size = 5.0;
assert_eq!(bevel_geom(&b).width, 3.0);
// The first row past the height blur's reach still slopes (its upper neighbour is raised),
// so it shades; the row after it does not.
let shape = square(60, 20, 40);
b.size = 9.0;
b.angle = 90.0;
let (m, _) = bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
let reach = tent_kernel(bevel_geom(&b).width).0 as usize;
let row = 39 + reach + 1; // last shape row + reach + 1: height 0, neighbour above raised
assert!(m[1].v[row * 60 + 30] > 0.0, "{}", m[1].v[row * 60 + 30]);
assert_eq!(m[1].v[(row + 1) * 60 + 30], 0.0);
}
#[test]
+18 -5
View File
@@ -19,13 +19,26 @@ use crate::effects;
/// Effective `(angle, scale, centre offset)` for a gradient fill with `style`, `angle` (degrees)
/// and `scale` laid out in `frame`.
pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame: Rect) -> (f32, f32, (f32, f32)) {
let unchanged = (angle, scale, (0.0, 0.0));
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected) || !angle.is_finite() || !scale.is_finite() {
gradient_layout(style, angle, scale, (0.0, 0.0), frame)
}
/// [`fill_gradient_layout`] for a gradient centred `offset` (a fraction of the frame) off the
/// frame's centre: layer-effect gradients (overlays, gradient glows and strokes) snap the same
/// way (psd-tools layer_effects: an 87° overlay on a 600 × 60 text line runs along (3, 60), i.e.
/// 87.14°).
pub fn gradient_layout(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> (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 = f64::from(frame.width().max(1));
let h = f64::from(frame.height().max(1));
let (cx, cy) = (f64::from(frame.x0) + w / 2.0, f64::from(frame.y0) + h / 2.0);
let (cx, cy) = (f64::from(frame.x0) + w / 2.0 + f64::from(offset.0) * w, f64::from(frame.y0) + h / 2.0 + f64::from(offset.1) * h);
// Unscaled chord length along `a` (radians), as `effects::gradient_t`.
let chord_of = |a: f64| {
let (s, c) = a.sin_cos();
@@ -55,8 +68,8 @@ pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame:
// 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 offset = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
(a2.to_degrees() as f32, scale2 as f32, offset)
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` of a gradient fill at pixel centre `(x, y)` (see [`fill_gradient_layout`]).
+4 -3
View File
@@ -516,13 +516,14 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
}
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
/// layer's pixel bounds: a shape layer's path bounds (rounded out to whole pixels). Its rendered
/// layer's pixel bounds: a shape layer's path bounds, truncated to whole pixels. Its rendered
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
/// psd-tools shape-fx2's 45° overlay spans the 29 px path, not the 32 px of pixels.
/// psd-tools shape-fx2's 45° overlay spans the 0.92–29.61 path as 0–29 (29 px, centred at 14.5),
/// not the 32 px of pixels.
pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
let LayerContent::Shape(sh) = &layer.content else { return None };
let (x0, y0, x1, y1) = sh.path.control_bounds()?;
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32);
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.floor() as i32, y1.floor() as i32);
(!r.is_empty()).then_some(r)
}
+31 -10
View File
@@ -131,17 +131,17 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
}
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
/// default): type layers mix their colour with the backdrop in a gamma-1.45 space, so
/// anti-aliased glyph edges come out lighter over dark backdrops than a linear mix (fitted on
/// psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay over grey is 57/255 in red,
/// a linear mix gives 40).
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
/// mix of the encoded values (psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay
/// over grey is 57/255 in red, an encoded mix gives 40).
pub const TEXT_GAMMA: f32 = 1.45;
static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3fb9_999a); // 1.45f32
/// Sets Color Settings › "Blend Text Colors Using Gamma" (1 = off). It is an application
/// setting, not stored in documents: files saved with it off (ag-psd float-color) mix type
/// linearly. Values are clamped to Photoshop's 1.00–2.20.
/// setting, not stored in documents (1 here means switched off: a plain mix of the encoded
/// values). Values are clamped to Photoshop's 1.00–2.20.
///
/// The value is process-wide: a test that changes it races every concurrently running test
/// that composites a type layer, so such tests belong in their own test binary (see
@@ -156,7 +156,24 @@ pub fn text_gamma() -> f32 {
f32::from_bits(TEXT_GAMMA_SETTING.load(std::sync::atomic::Ordering::Relaxed))
}
/// [`composite`] with the coverage mix done in a `gamma` space (`gamma == 1` is [`composite`]).
/// Into the text blending space: linear light raised to `1 / gamma`. Gamma 2.2 is close to
/// mixing the encoded values, lower settings approach linear light. Fitted on psd-tools
/// layer_effects.psd (a blue colour overlay's glyph edges over grey, every coverage level within
/// 0.6/255; a plain `v^1.45` power mix is 6/255 too light at 94 % coverage) and ag-psd
/// float-color.
#[inline]
pub fn text_encode(v: f32, gamma: f32) -> f32 {
photocraft_color::convert::srgb_to_linear(v.max(0.0)).powf(1.0 / gamma)
}
/// Inverse of [`text_encode`].
#[inline]
pub fn text_decode(v: f32, gamma: f32) -> f32 {
photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma))
}
/// [`composite`] with the coverage mix done in the text blending space of `gamma`
/// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off).
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
if gamma == 1.0 {
return composite(mode, backdrop, source, opacity);
@@ -174,10 +191,11 @@ pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], op
let cs = [source[0], source[1], source[2]];
let b = blend_rgb(mode, cb, cs);
let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao);
let g = |v: f32| v.max(0.0).powf(gamma);
let g = |v: f32| text_encode(v, gamma);
let ginv = |v: f32| text_decode(v, gamma);
let mut out = [0.0f32; 4];
for i in 0..3 {
out[i] = (wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i])).powf(1.0 / gamma);
out[i] = ginv(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i]));
}
out[3] = ao;
out
@@ -189,7 +207,10 @@ mod tests {
fn text_gamma_lightens_dark_edges() {
// 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146).
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA);
assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}");
assert!((r[0] * 255.0 - 57.0).abs() < 1.0 && (r[1] * 255.0 - 80.0).abs() < 1.0 && (r[2] * 255.0 - 146.0).abs() < 1.0, "{r:?}");
// Near-opaque edges stay dark: 94 % coverage shows (13, 57, 152) (a v^1.45 mix gives 19 red).
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.937], 1.0, TEXT_GAMMA);
assert!((r[0] * 255.0 - 13.0).abs() < 1.0 && (r[1] * 255.0 - 57.0).abs() < 1.0 && (r[2] * 255.0 - 152.0).abs() < 1.0, "{r:?}");
assert_eq!(
composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0),
composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)
+47
View File
@@ -783,6 +783,53 @@ fn inside_stroke_respects_partial_edge_coverage() {
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
}
#[test]
fn emboss_shades_the_composited_layer() {
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
// the ratio of type to raster results equals that of their plain composites.
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
enabled: true,
style: photocraft_doc::BevelStyle::Emboss,
technique: photocraft_doc::BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 6.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
shadow_color: Color::BLACK,
contour: None,
texture: None,
});
let make = |text: bool, fx: bool| {
let mut d = doc_white(40, 40);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
if text {
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
l = Layer::new("t", LayerContent::Text(t));
}
if fx {
l.effects.items = vec![emboss.clone()];
}
d.layers.push(l);
px(&d, 20, 10)
};
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
for c in 0..3 {
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
}
}
#[test]
fn interior_effects_keep_the_layer_alpha() {
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
+41 -14
View File
@@ -38,6 +38,7 @@ const F_MASK_TEX: u32 = 2u; // mask pixels live in `mask_tex`
const F_TEX: u32 = 4u; // layer pixels live in `layer_tex`
const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`)
const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow)
const F_NO_LAYER: u32 = 32u; // effect merge: A already holds the layer (only the opacity mix)
const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside)
const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base
const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape
@@ -240,7 +241,17 @@ fn composite(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32) -> vec4<f32> {
return vec4(rgb, ao);
}
// `psblend::composite_gamma`: coverage mixed in a gamma space (type layers, gamma 1.45).
// `psblend::text_encode` / `text_decode`: linear light raised to 1 / gamma.
fn text_enc(c: vec3<f32>, g: f32) -> vec3<f32> {
let l = vec3(srgb_to_linear(max(c.r, 0.0)), srgb_to_linear(max(c.g, 0.0)), srgb_to_linear(max(c.b, 0.0)));
return pow(l, vec3(1.0 / g));
}
fn text_dec(v: vec3<f32>, g: f32) -> vec3<f32> {
let l = pow(max(v, vec3(0.0)), vec3(g));
return vec3(linear_to_srgb(l.r), linear_to_srgb(l.g), linear_to_srgb(l.b));
}
// `psblend::composite_gamma`: coverage mixed in the text blending space (type layers, 1.45).
fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bool) -> vec4<f32> {
if (!gamma_on) { return composite(mode, b, s, opacity); }
let ab = b.a;
@@ -250,8 +261,8 @@ fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bo
let ao = as_ + ab * (1.0 - as_);
if (ao <= 0.0) { return vec4(0.0); }
let g = op.p4.w; // psblend::text_gamma
let pw = (1.0 - as_) * ab * pow(max(b.rgb, vec3(0.0)), vec3(g)) + (1.0 - ab) * as_ * pow(max(s.rgb, vec3(0.0)), vec3(g)) + as_ * ab * pow(max(bl, vec3(0.0)), vec3(g));
return vec4(pow(pw / ao, vec3(1.0 / g)), ao);
let pw = (1.0 - as_) * ab * text_enc(b.rgb, g) + (1.0 - ab) * as_ * text_enc(s.rgb, g) + as_ * ab * text_enc(bl, g);
return vec4(text_dec(pw / ao, g), ao);
}
// photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB).
@@ -593,13 +604,15 @@ fn gradient_t(d: vec2<i32>) -> f32 {
let len = max(sqrt((c * w) * (c * w) + (s * h) * (s * h)), 1.0) * max(op.p0.y, 1e-3);
// effects::gradient_t: Linear / Reflected span the bounds' chord along the angle.
let chord = max(min(w / max(abs(c), 1e-6), h / max(abs(s), 1e-6)), 1.0) * max(op.p0.y, 1e-3);
// Linear / Reflected sample the pixel's top-left corner (effects::gradient_t).
let corner = 0.5 * (c - s);
var t: f32;
switch i32(op.p0.w) {
case 1: { t = sqrt(dx * dx + dy * dy) / (len / 2.0); } // Radial
case 2: { t = rem_euclid((a - atan2(-dy, dx)) / 6.28318530718, 1.0); } // Angle
case 3: { t = abs(along / (chord / 2.0)); } // Reflected
case 3: { t = abs((along - corner) / (chord / 2.0)); } // Reflected
case 4: { t = (abs(along) + abs(across)) / (len / 2.0); } // Diamond
default: { t = along / chord + 0.5; } // Linear
default: { t = (along - corner) / chord + 0.5; } // Linear
}
t = clamp(t, 0.0, 1.0);
if (op.p0.z > 0.5) { t = 1.0 - t; }
@@ -880,7 +893,7 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4<f32> {
var w = textureLoad(tex_a, p, 0);
let l = textureLoad(tex_b, p, 0);
let c = textureLoad(tex_c, p, 0);
if (l.a > 0.0) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
let atop = (op.flags & F_ATOP) != 0u;
var before = c;
if (atop) { before = vec4(c.rgb, 1.0); }
@@ -1000,19 +1013,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
@fragment
fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
let depth = op.p1.x;
let s = textureLoad(layer_tex, p, 0).r;
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
// 4-neighbour's).
var hmax = ra(p);
if (op.p1.y > 1.5) {
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
}
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
// p1.y: 0 inside (× alpha), 1 under the edge and outside (outer bevel), 2 / 3 emboss / pillow
// emboss (inside where the shape is, plus the outside half, flipped for pillow, × 1 − alpha).
if (op.p1.y < 0.5) { return mout(bevel_part(p, op.p1.x, s)); }
if (op.p1.y < 1.5) { return mout(bevel_part(p, op.p1.x, outside)); }
var inner = 0.0;
if (s > INSIDE_EPS) { inner = bevel_part(p, op.p1.x, s); }
let od = select(op.p1.x, -op.p1.x, op.p1.y > 2.5);
return mout(inner + bevel_part(p, od, outside) * (1.0 - min(s, 1.0)));
}
// One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for
// height-gradient scale `depth`, times `region`, through the gloss contour.
fn bevel_part(p: vec2<i32>, depth: f32, region: f32) -> f32 {
let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth;
let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth;
let n = vec3(-gx, -gy, 1.0);
let len = sqrt(n.x * n.x + n.y * n.y + 1.0);
let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len;
let se = op.p0.w;
let s = textureLoad(layer_tex, p, 0).r;
var region = s;
if (op.p1.y > 0.5) {
// Outside parts paint at full strength wherever the shape isn't opaque (edge pixels too).
region = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && textureLoad(tex_a, p, 0).r > 1e-5); // effects::BEVEL_H_EPS
}
let k = shade - se;
var v = 0.0;
if (op.p1.z > 0.5) {
@@ -1021,7 +1048,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region;
}
if (op.p1.w > 0.5) { v = lut(0, v); }
return mout(v);
return v;
}
// Bevel texture (`effects::bevel_height`): A + k × luminance of the pattern (× its alpha; 1 −
+13 -16
View File
@@ -290,7 +290,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
if r > 0.0 {
// dist_outside of the shifted map is the shifted field (integer offsets; shifted-in
// pixels are far away for a drop shadow, inside for an inner shadow).
let f = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
let f = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
let d = if dx == 0.0 && dy == 0.0 {
f
} else {
@@ -321,7 +321,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread);
let mut m = src;
if r > 0.0 {
let d = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
let d = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
m = b.dilate(src, d, r);
}
let m = b.blur(m, bw);
@@ -385,16 +385,18 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
let (sa, ca) = angle.to_radians().sin_cos();
let (se, ce) = altitude.to_radians().sin_cos();
let lut = contour_lut(&bv.gloss_contour).map(Arc::new);
let passes: Vec<(f32, bool)> = match g.paint {
BevelPaint::Inner => vec![(g.depth, false)],
BevelPaint::Outer => vec![(g.depth, true)],
BevelPaint::Both => vec![(g.depth, false), (if g.pillow { -g.depth } else { g.depth }, true)],
// Region (`fs_mbevelshade`): 0 inside, 1 under the edge and outside, 2 / 3 emboss /
// pillow emboss (both halves in one map).
let passes: Vec<(f32, f32)> = match g.paint {
BevelPaint::Inner => vec![(g.depth, 0.0)],
BevelPaint::Outer => vec![(g.depth, 1.0)],
BevelPaint::Both => vec![(g.depth, if g.pillow { 3.0 } else { 2.0 })],
};
let mut out = 0;
for (depth, outer) in &passes {
for (depth, region) in &passes {
for which in [0.0, 1.0] {
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
s.p1 = [*depth, f32::from(u8::from(*outer)), which, f32::from(u8::from(lut.is_some()))];
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
s.lut = lut.clone();
s.s = Some(In::Shape);
s.out = Some(out);
@@ -681,14 +683,9 @@ mod tests {
shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 };
}
}
for kind in [
FieldKind::Outside,
FieldKind::Inside,
FieldKind::OutsideInverse,
FieldKind::StrokeOutside,
FieldKind::StrokeInside,
FieldKind::StrokeOutsideVector,
] {
for kind in
[FieldKind::Outside, FieldKind::Inside, FieldKind::ChokeInside, FieldKind::StrokeOutside, FieldKind::StrokeInside, FieldKind::StrokeOutsideVector]
{
let reach = 9;
let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h);
let banded = field(kind, reach, &shape, region, region);
+56 -18
View File
@@ -338,6 +338,8 @@ enum Cov {
/// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`;
/// stroke-out `inside ? (vector ? 0 : 1) : m`.
pub const F_KNOCKOUT: u32 = 16;
/// Effect merge: A already holds the layer; only mix with the backdrop by the layer's opacity.
pub const F_NO_LAYER: u32 = 32;
/// Shape layer (outside strokes never show inside it).
pub const F_VECTOR: u32 = 64;
/// Merge onto an opaque clipping base, keeping its alpha.
@@ -926,11 +928,11 @@ impl<'a> Planner<'a> {
}
}
}
let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint;
for &(i, e) in &rev {
if let Effect::BevelEmboss(b) = e
&& b.style != photocraft_doc::BevelStyle::OuterBevel
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Inner
{
// Inner part (emboss styles: maps 0–1 inside, 2–3 outside).
l = self.paint(
l,
content,
@@ -990,10 +992,9 @@ impl<'a> Planner<'a> {
}
for &(i, e) in &rev {
if let Effect::BevelEmboss(b) = e
&& let paint = photocraft_compose::effects::bevel_geom(b).paint
&& paint != photocraft_compose::effects::BevelPaint::Inner
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer
{
let k = if paint == photocraft_compose::effects::BevelPaint::Both { 2 } else { 0 };
let k = 0;
w = self.paint(
w,
content,
@@ -1009,16 +1010,51 @@ impl<'a> Planner<'a> {
}
}
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(backdrop));
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
let merged = self.emit(p);
let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev
.iter()
.filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None })
.filter(|(_, b)| bevel_paint(b) == photocraft_compose::effects::BevelPaint::Both)
.collect();
let merged = if late.is_empty() {
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(backdrop));
p.mode = mode;
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
self.emit(p)
} else {
// Emboss styles shade the composited layer (`composite_with_effects`): merge at full
// opacity, paint their maps (inside and outside halves), then mix.
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(w));
p.mode = mode;
p.opacity = 1.0;
p.flags = gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
let mut m = self.emit(p);
for (i, b) in late {
for (k, color, fxc) in [(0, &b.highlight_color, &b.highlight), (1, &b.shadow_color, &b.shadow)] {
m = self.paint(m, content, Cov::Map(map(i, k), 0.0), &Paint::Color(color.to_rgb()), fxc.blend, fxc.opacity, 0, clip, sb);
}
}
let mut p = Pass::new(Kernel::FxMerge, 0);
p.b = Some(self.retain(m));
p.a = Some(m);
p.c = Some(self.retain(backdrop));
p.mode = mode;
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | F_NO_LAYER;
p.clip = Some(clip);
self.emit(p)
};
self.release(content);
// Write the clipped result back into the backdrop (in place when nothing else holds it).
@@ -1082,10 +1118,12 @@ impl<'a> Planner<'a> {
Paint::None => {}
Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0],
Paint::Gradient(g) => {
p.params[0] = [g.angle, g.scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
// effects::paint_fx: whole-pixel end points (fill_layout::gradient_layout).
let (angle, scale, offset) = photocraft_compose::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, sb);
p.params[0] = [angle, scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
p.params[1] = [sb.x0 as f32, sb.y0 as f32, sb.width() as f32, sb.height() as f32];
p.params[2][0] = g.offset.0;
p.params[2][1] = g.offset.1;
p.params[2][0] = offset.0;
p.params[2][1] = offset.1;
p.params[2][2] = 1.0;
let mut rows = vec![[0.0f32; 4096]; 4];
for k in 0..4096 {
+18
View File
@@ -1001,6 +1001,24 @@ fn type_layers_blend_with_text_gamma() {
];
d.layers.push(l);
fx_check(&mut g, &d, "text fx");
// Emboss styles shade the composited layer (merge, shade, then the opacity mix), also as a
// clipped layer over an opaque base.
let mut e = fx_doc(96, 80, SampleType::U8);
let mut l = as_text(blob("emboss", e.pixel_format(), 46.0, 40.0, 20.0, [0.4, 0.1, 0.3]));
l.effects.items = vec![
Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 9.0, 0.0)),
Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, false, 5.0, 1.0)),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)),
];
l.opacity = 0.7;
l.fill_opacity = 0.6;
e.layers.push(l.clone());
fx_check(&mut g, &e, "text emboss");
let mut c = l;
c.clipped = true;
c.blend = BlendMode::Multiply;
e.layers.push(c);
fx_check(&mut g, &e, "clipped text emboss");
// The gamma changes edge pixels against a linear mix.
let flat = photocraft_compose::flatten(&d);
let mut lin = d.clone();
+51 -1
View File
@@ -10,7 +10,9 @@
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 layerpx x y # each layer's pixel
//! cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd # every file: max err, bad %, PASS/DIFF
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 dump prefix # raw f32 planes for offline fitting
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 bylayer # max error around each layer
//! HIDE_ADJ=1 … # adjustment layers hidden
//! ONLY="name,name" … # only these top-level layers (+ the bottom one)
//! DUMP_FX=1 … # raw effects descriptors
//! ```
fn main() {
@@ -36,6 +38,14 @@ fn main() {
}
hide(&mut imp.document.layers);
}
if let Some(keep) = std::env::var_os("ONLY") {
// Hide every top-level layer but the bottom one and those named in ONLY (comma-separated).
let keep = keep.to_string_lossy().to_string();
let names: Vec<&str> = keep.split(',').collect();
for l in imp.document.layers.iter_mut().skip(1) {
l.visible = names.contains(&l.name.as_str());
}
}
let doc = &imp.document;
println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light);
for l in doc.walk() {
@@ -173,7 +183,7 @@ fn main() {
}
}
for rec in file.layers() {
println!("-- layer {:?}", String::from_utf8_lossy(&rec.name));
println!("-- layer {:?} rect {:?}", String::from_utf8_lossy(&rec.name), rec.rect);
if let Some(b) = rec.block(b"lmfx").or(rec.block(b"lfx2")).or(rec.block(b"lfxs"))
&& let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..])
{
@@ -264,6 +274,46 @@ fn main() {
}
return;
}
if std::env::args().nth(3).as_deref() == Some("bylayer") {
// Max error and bad-pixel count inside each layer's bounds grown by its effects' reach
// (regions overlap; a quick way to tell which layer or effect is off).
let (w, h) = (doc.size.width as i32, doc.size.height as i32);
for (_, _, l) in doc.walk() {
let Some(sf) = l.surface() else { continue };
let cb = sf.content_bounds();
let mut b = photocraft_geom::Rect::new(i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for y in cb.y0..cb.y1 {
for x in cb.x0..cb.x1 {
let p = sf.pixel(x, y);
if p[p.len() - 1] > 0.0 {
b = photocraft_geom::Rect::new(b.x0.min(x), b.y0.min(y), b.x1.max(x + 1), b.y1.max(y + 1));
}
}
}
if b.x0 > b.x1 {
continue;
}
let m = photocraft_compose::effects::margin(l);
let r = photocraft_geom::Rect::new((b.x0 - m).max(0), (b.y0 - m).max(0), (b.x1 + m).min(w), (b.y1 + m).min(h));
let (mut worst, mut at, mut bad) = (0.0f32, (0, 0), 0usize);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let i = (y * w + x) as usize;
let (a, b) = (ours[i], merged[i]);
let d = (0..3).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max);
if d > 2.0 / 255.0 {
bad += 1;
}
if d > worst {
(worst, at) = (d, (x, y));
}
}
}
let fx: Vec<&str> = l.effects.items.iter().filter(|e| e.enabled()).map(|e| e.label()).collect();
println!("{:<32} {:>6.1} at {:?} bad {:>6} {:?} {:?}", l.name, worst * 255.0, at, bad, l.blend, fx);
}
return;
}
if std::env::args().nth(3).as_deref() == Some("worst") {
// The N worst pixels (premultiplied max channel error), with coordinates.
let w = doc.size.width as usize;
+56 -1
View File
@@ -47,8 +47,35 @@ const BLEND_NAMES: [(BlendMode, &str); 28] = [
(BlendMode::PassThrough, "passThrough"),
];
/// Long (string-ID) names some writers use instead of the four-character codes, for the modes
/// whose code is a short ID (`BlnM` `multiply` = `Mltp`). The others already use their long name.
const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
(BlendMode::Normal, "normal"),
(BlendMode::Dissolve, "dissolve"),
(BlendMode::Darken, "darken"),
(BlendMode::Multiply, "multiply"),
(BlendMode::ColorBurn, "colorBurn"),
(BlendMode::Lighten, "lighten"),
(BlendMode::Screen, "screen"),
(BlendMode::ColorDodge, "colorDodge"),
(BlendMode::Overlay, "overlay"),
(BlendMode::SoftLight, "softLight"),
(BlendMode::HardLight, "hardLight"),
(BlendMode::Difference, "difference"),
(BlendMode::Exclusion, "exclusion"),
(BlendMode::Hue, "hue"),
(BlendMode::Saturation, "saturation"),
(BlendMode::Color, "color"),
(BlendMode::Luminosity, "luminosity"),
];
/// A blend-mode enum from either its four-character code or its long string ID.
fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
}
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
enum_of(d, key).and_then(|v| BLEND_NAMES.iter().find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)).unwrap_or(default)
enum_of(d, key).and_then(blend_from_id).unwrap_or(default)
}
fn blend_value(m: BlendMode) -> Value {
@@ -721,6 +748,34 @@ mod tests {
}
}
#[test]
fn long_string_blend_ids_parse() {
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
// instead of the four-character codes (psd-tools effects/blend-modes.psd).
let long = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
let d = Descriptor::new("null")
.with("masterFXSwitch", Value::Boolean(true))
.with("DrSh", Value::Descriptor(Descriptor::new("DrSh").with("enab", Value::Boolean(true)).with("Md ", long("linearBurn"))))
.with("ebbl", Value::Descriptor(Descriptor::new("ebbl").with("hglM", long("colorDodge")).with("sdwM", long("colorBurn"))))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("Md ", long("hue"))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
let (_, fx) = parse_lfx2(&data).unwrap();
let modes: Vec<BlendMode> = fx
.iter()
.flat_map(|e| match e {
Effect::DropShadow(s) => vec![s.common.blend],
Effect::BevelEmboss(b) => vec![b.highlight.blend, b.shadow.blend],
Effect::ColorOverlay { common, .. } => vec![common.blend],
_ => vec![],
})
.collect();
assert_eq!(modes, vec![BlendMode::LinearBurn, BlendMode::ColorDodge, BlendMode::ColorBurn, BlendMode::Hue]);
for (m, n) in BLEND_LONG_NAMES {
assert_eq!(blend_from_id(n.as_bytes()), Some(m));
}
}
#[test]
fn garbage_is_none() {
assert!(parse_lfx2(&[0, 0]).is_none());
+4 -4
View File
@@ -137,13 +137,13 @@ struct Source {
roundtrip_floor: usize,
}
/// Hand-picked mix in `corpus/psd` (170 files: 128 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 140, roundtrip_floor: 169 };
/// Hand-picked mix in `corpus/psd` (170 files: 133 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 145, roundtrip_floor: 169 };
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 212 vs the merged image + 10 vs the thumbnail.
/// 218 vs the merged image + 10 vs the thumbnail.
const PSD_TOOLS: Source =
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 222, roundtrip_floor: 307 };
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 228, roundtrip_floor: 307 };
/// The corpus directory and whether floors are enforced; `None` when absent.
fn locate(src: &Source) -> Option<(PathBuf, bool)> {