mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 19:56:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf9e8825c |
No files matched your search
+122
-35
@@ -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]
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
Reference in new issue
Block a user