mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 19:05:20 +02:00
Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215) Blending Options > Advanced Blending now works end to end: - doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend interior effects as group, blend clipped layers as group, transparency shapes layer, layer/vector mask hides effects), saved in .pcraft. - PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the model and written back (in place, or added when not at the defaults); the TIFF byte-order transcoder handles lmgm/vmgm as byte flags. - CPU compositor (compose/src/advanced.rs): shallow and deep knockouts (isolated groups and clipping groups stop a deep knockout), clipped layers blended individually, effects over the whole layer when Transparency Shapes is off, effects built from an unmasked copy when a mask hides them, interior effects under fill opacity. - GPU: documents using Advanced Blending fall back to the CPU compositor. - engine: layer.layerStyle.blendingOptions takes channels, knockout and the five switches; inspect reports them; Copy/Paste Layer Style carries them. - UI: the Blending Options page laid out like Photoshop's (General, Advanced Blending, Blend If with split sliders), translated. psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor raised); other corpora and all round trips unchanged. Scorecard FILE-215-16 -> partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard #810's exhaustive Layer destructure must classify the new advanced-blending field; it changes the composite, so it is compared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: leon <claude.udder842@passmail.net> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
42 files changed
+1858
-87
No files matched your search
@@ -0,0 +1,278 @@
|
||||
//! Layer Style › Blending Options › Advanced Blending in the CPU compositor (the oracle):
|
||||
//!
|
||||
//! - **Knockout.** Inside the layer's shape the backdrop is replaced by the knockout target
|
||||
//! before the layer composites: for Shallow the bottom of the enclosing group (the backdrop the
|
||||
//! group's stack started from: transparent in an isolated group, the layers beneath in a
|
||||
//! pass-through one) or of the clipping group (the base layer's content); for Deep, and for
|
||||
//! Shallow at the top level, the Background layer (transparency without one). Isolated groups
|
||||
//! and grouped clipping layers stop a deep knockout at their bottom, as Photoshop renders it
|
||||
//! (see [`Scope`]); pass-through groups let it through. Fill opacity sets how much of the layer
|
||||
//! shows over the knocked-out area, layer opacity how strong the whole knockout is.
|
||||
//! - **Blend Clipped Layers as Group off.** The base composites alone; each clipped layer then
|
||||
//! composites onto the result in its own mode, kept inside the base's shape (its alpha with
|
||||
//! masks, times its opacity).
|
||||
//! - **Transparency Shapes Layer off.** The layer's effects and knockout cover the whole layer
|
||||
//! (its masks still apply) instead of following its transparency.
|
||||
//! - **Layer / Vector Mask Hides Effects.** The effects are built from the layer without that
|
||||
//! mask, and the mask is applied to the finished layer and effects.
|
||||
//!
|
||||
//! Blend Interior Effects as Group lives in [`crate::effects`], where interior effects are
|
||||
//! painted. Every path here is skipped for layers at Photoshop's defaults, so documents that
|
||||
//! don't use these options render exactly as before at no extra cost.
|
||||
//!
|
||||
//! Measured on the psd-tools knockout files (deep/shallow, nested, pass-through, no Background,
|
||||
//! groups with knockout). Not yet measured against Photoshop: the clip of individually blended
|
||||
//! clipped layers (base alpha × opacity, ignoring its fill), Transparency Shapes Layer off, and the
|
||||
//! masks hiding effects.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use photocraft_doc::{Knockout, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content};
|
||||
|
||||
/// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an
|
||||
/// isolated (non-pass-through) group. Photoshop stops a deep knockout at the bottom of the
|
||||
/// innermost isolated group around it (psd-tools knockout-deep-nested renders exactly like
|
||||
/// knockout-shallow-nested), while pass-through groups let it reach the Background
|
||||
/// (knockout-deep-nested-pt renders like knockout-deep-normal).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Scope {
|
||||
/// The document's top-level stack: a shallow knockout there reaches the Background.
|
||||
pub root: bool,
|
||||
/// Inside an isolated group: deep knockouts stop at its (transparent) bottom.
|
||||
pub isolated: bool,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub const ROOT: Scope = Scope { root: true, isolated: false };
|
||||
/// An isolated group's own stack (it starts from a transparent buffer).
|
||||
pub const ISOLATED: Scope = Scope { root: false, isolated: true };
|
||||
|
||||
/// The stack of a pass-through group inside this one.
|
||||
pub fn pass_through(self) -> Scope {
|
||||
Scope { root: false, isolated: self.isolated }
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied linear interpolation between two straight-alpha pixels.
|
||||
#[inline]
|
||||
fn mix(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
||||
let alpha = a[3] + (b[3] - a[3]) * k;
|
||||
if alpha <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let c = |c: usize| ((a[c] * a[3] + (b[c] * b[3] - a[c] * a[3]) * k) / alpha).clamp(0.0, 1.0);
|
||||
[c(0), c(1), c(2), alpha.min(1.0)]
|
||||
}
|
||||
|
||||
/// Whether any Advanced Blending option changes how `layer` (with its clipping group `clipped`)
|
||||
/// composites. The GPU compositor sends such documents to this CPU path.
|
||||
pub fn advanced_active(layer: &Layer, clipped: &[Layer]) -> bool {
|
||||
let a = &layer.advanced;
|
||||
if a.is_default() {
|
||||
return false;
|
||||
}
|
||||
let fx = effects::has_effects(layer);
|
||||
a.knockout != Knockout::None || clips_individually(layer, clipped) || (a.blend_interior && fx) || (!a.transparency_shapes && fx) || hides_effects(layer)
|
||||
}
|
||||
|
||||
/// Whether the layer's effects (or knockout) cover the whole layer: Transparency Shapes Layer
|
||||
/// off on a layer that has either.
|
||||
pub fn shapeless(layer: &Layer) -> bool {
|
||||
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
|
||||
}
|
||||
|
||||
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
|
||||
/// named "Background" (how PSD import and File › New create it).
|
||||
pub fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
|
||||
/// layer (transparency when it has none, or when it is hidden).
|
||||
pub(crate) fn deep_target(cx: &Ctx, rect: Rect) -> Buffer {
|
||||
let Some(doc) = cx.doc else { return Buffer::transparent(rect) };
|
||||
let mut b = crate::multichannel::backdrop(doc, rect);
|
||||
if let Some(bg) = doc.layers.first().filter(|l| l.visible && is_background(l)) {
|
||||
composite_layer(bg, &[], &mut b, cx, None, Scope::ROOT);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// The knockout target of a layer of a stack in `scope`, if it knocks out. `floor` is the
|
||||
/// stack's starting backdrop (`None` at the top level, where a shallow knockout reaches the
|
||||
/// Background too).
|
||||
pub(crate) fn knockout_target<'b>(layer: &Layer, floor: Option<&'b Buffer>, rect: Rect, cx: &Ctx, scope: Scope) -> Option<Cow<'b, Buffer>> {
|
||||
let deep = || if scope.isolated { Buffer::transparent(rect) } else { deep_target(cx, rect) };
|
||||
match layer.advanced.knockout {
|
||||
Knockout::None => None,
|
||||
Knockout::Shallow => Some(floor.map_or_else(|| Cow::Owned(deep()), Cow::Borrowed)),
|
||||
Knockout::Deep => Some(Cow::Owned(deep())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `layers` holds a visible unclipped shallow knockout (its stack then keeps a copy of
|
||||
/// its starting backdrop).
|
||||
pub(crate) fn needs_floor(layers: &[Layer]) -> bool {
|
||||
layers.iter().any(|l| l.visible && !l.clipped && l.advanced.knockout == Knockout::Shallow)
|
||||
}
|
||||
|
||||
/// The shape a layer knocks out over `rect` (row-major): its content's alpha with its masks;
|
||||
/// with Transparency Shapes Layer off (and for adjustment layers) just the masks.
|
||||
fn knockout_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
let masks = || mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]);
|
||||
if !layer.advanced.transparency_shapes {
|
||||
return masks();
|
||||
}
|
||||
match render_content(layer, rect, cx) {
|
||||
Some(b) => b.px.iter().map(|p| p[3]).collect(),
|
||||
None => masks(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite `layer` with `draw` as a knockout onto `target`: inside its shape the backdrop
|
||||
/// becomes the target, then the layer draws (at its fill opacity), and layer opacity mixes the
|
||||
/// result with the untouched backdrop. Compositing at opacity `o` is linear in `o`
|
||||
/// (premultiplied), so with `B` the backdrop, `B'` the knocked-out one and `R'` what `draw`
|
||||
/// produced at the layer's opacity, the result is `R' + (1 − o)(B − B')`.
|
||||
///
|
||||
/// `clip_alpha` keeps alpha from growing (clipped layers stay inside their base's alpha; a
|
||||
/// knockout to transparency may still lower it).
|
||||
pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, clip_alpha: bool, cx: &Ctx, draw: impl FnOnce(&mut Buffer)) {
|
||||
// Coverage knocks out proportionally. (Photoshop knocks out a little more at anti-aliased
|
||||
// shape edges: psd-tools knockout-isolated-groups differs on 0.45 % of its pixels, along
|
||||
// ellipse edges; a binary shape matched worse.)
|
||||
let shape = knockout_shape(layer, backdrop.rect, cx);
|
||||
if target.rect != backdrop.rect || shape.len() != backdrop.px.len() {
|
||||
// Never reached (targets are built over the backdrop's rect); compositing without the
|
||||
// knockout beats a wrong pixel or a panic.
|
||||
draw(backdrop);
|
||||
return;
|
||||
}
|
||||
let before = backdrop.clone();
|
||||
for ((p, t), k) in backdrop.px.iter_mut().zip(&target.px).zip(&shape) {
|
||||
if *k > 0.0 {
|
||||
let a = p[3];
|
||||
*p = mix(*p, *t, k.min(1.0));
|
||||
if clip_alpha {
|
||||
p[3] = p[3].min(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let knocked = (layer.opacity < 1.0).then(|| backdrop.clone());
|
||||
draw(backdrop);
|
||||
let Some(knocked) = knocked else { return };
|
||||
let rest = 1.0 - layer.opacity.clamp(0.0, 1.0);
|
||||
for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) {
|
||||
if *s <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut pm = [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]];
|
||||
for c in 0..3 {
|
||||
pm[c] += rest * (b[c] * b[3] - k[c] * k[3]);
|
||||
}
|
||||
pm[3] += rest * (b[3] - k[3]);
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
*p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] };
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend
|
||||
/// Clipped Layers as Group on). The grouped clipping layers composite in isolation, like an
|
||||
/// isolated group, so a clipped layer's knockout (shallow or deep) stops at the bottom of the
|
||||
/// clipping group: the base's content.
|
||||
pub(crate) fn composite_clipped(clipped: &[Layer], base: &mut Buffer, cx: &Ctx) {
|
||||
let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout != Knockout::None).then(|| base.clone());
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
match &floor {
|
||||
Some(f) if c.advanced.knockout != Knockout::None => knockout(c, base, f, true, cx, |b| composite_atop(c, b, cx)),
|
||||
_ => composite_atop(c, base, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `layer`'s clipped layers blend one by one (Blend Clipped Layers as Group off).
|
||||
/// Adjustment layers keep the grouped path (they have no pixels of their own to clip to).
|
||||
pub(crate) fn clips_individually(layer: &Layer, clipped: &[Layer]) -> bool {
|
||||
!layer.advanced.blend_clipped && !matches!(layer.content, LayerContent::Adjustment(_)) && clipped.iter().any(|c| c.visible)
|
||||
}
|
||||
|
||||
/// Blend Clipped Layers as Group off: the base alone (with its knockout `ko`), then each visible
|
||||
/// clipped layer composited in its own mode onto the result, inside the base's shape (its alpha
|
||||
/// with masks, times its opacity). A clipped layer's shallow knockout stops at the backdrop
|
||||
/// beneath the base (the bottom of the clipping group).
|
||||
pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: Scope) {
|
||||
let rect = backdrop.rect;
|
||||
let n = backdrop.px.len();
|
||||
let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout == Knockout::Shallow).then(|| backdrop.clone());
|
||||
composite_layer(layer, &[], backdrop, cx, ko, scope);
|
||||
let op = layer.opacity.clamp(0.0, 1.0);
|
||||
let clip: Vec<f32> = match render_content(layer, rect, cx) {
|
||||
Some(b) => b.px.iter().map(|p| p[3] * op).collect(),
|
||||
None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(),
|
||||
};
|
||||
if clip.len() != n {
|
||||
return;
|
||||
}
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
let before = backdrop.clone();
|
||||
let t = knockout_target(c, floor.as_ref(), rect, cx, Scope { root: false, ..scope });
|
||||
composite_layer(c, &[], backdrop, cx, t.as_deref(), scope);
|
||||
for ((p, b), k) in backdrop.px.iter_mut().zip(&before.px).zip(&clip) {
|
||||
if *k < 1.0 {
|
||||
*p = mix(*b, *p, k.max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mask of `layer` hides its effects instead of shaping them (Layer / Vector Mask
|
||||
/// Hides Effects on an enabled mask of a layer with effects).
|
||||
pub(crate) fn hides_effects(layer: &Layer) -> bool {
|
||||
let a = &layer.advanced;
|
||||
effects::has_effects(layer)
|
||||
&& ((a.layer_mask_hides_effects && layer.mask.as_ref().is_some_and(|m| m.enabled))
|
||||
|| (a.vector_mask_hides_effects && layer.vector_mask.as_ref().is_some_and(|m| m.enabled)))
|
||||
}
|
||||
|
||||
/// The layer whose content and effect maps are composited: `layer` itself, or (when a mask hides
|
||||
/// its effects) a copy without that mask. Pixels are shared copy-on-write, so the copy costs a
|
||||
/// tile table, and its effect maps cache under their own key.
|
||||
pub(crate) fn effects_source(layer: &Layer) -> Cow<'_, Layer> {
|
||||
if !hides_effects(layer) {
|
||||
return Cow::Borrowed(layer);
|
||||
}
|
||||
let mut l = layer.clone();
|
||||
if l.advanced.layer_mask_hides_effects {
|
||||
l.mask = None;
|
||||
}
|
||||
if l.advanced.vector_mask_hides_effects {
|
||||
l.vector_mask = None;
|
||||
}
|
||||
Cow::Owned(l)
|
||||
}
|
||||
|
||||
/// The masks that hide `layer`'s effects over `rect` (row-major; `None` when none do).
|
||||
pub(crate) fn hiding_mask(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
if !hides_effects(layer) {
|
||||
return None;
|
||||
}
|
||||
let a = &layer.advanced;
|
||||
let mut only = Layer::new(layer.name.clone(), LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA32F)));
|
||||
only.id = layer.id;
|
||||
only.mask = layer.mask.clone().filter(|_| a.layer_mask_hides_effects);
|
||||
only.vector_mask = layer.vector_mask.clone().filter(|_| a.vector_mask_hides_effects);
|
||||
mask_vals(&only, rect, cx)
|
||||
}
|
||||
|
||||
/// Mix `out` back towards `before` where `mask` hides the layer: `out ← mix(before, out, mask)`.
|
||||
pub(crate) fn apply_hiding_mask(out: &mut Buffer, before: &Buffer, mask: &[f32]) {
|
||||
for ((p, b), k) in out.px.iter_mut().zip(&before.px).zip(mask) {
|
||||
if *k < 1.0 {
|
||||
*p = mix(*b, *p, k.max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Advanced Blending (knockout, blend clipped / interior as group, transparency shapes, masks
|
||||
//! hide effects) on small synthetic documents with hand-computed pixels, at 8, 16 and 32 bits.
|
||||
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Effect, FxCommon, FxPaint, Knockout, LayerMask, StrokeFx, StrokePosition};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
const E: f32 = 2.0 / 255.0;
|
||||
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
|
||||
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
const GRAY: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn check(d: &Document, x: i32, y: i32, want: [f32; 4], what: &str) {
|
||||
let got = render(d, Rect::from_xywh(x, y, 1, 1)).px[0];
|
||||
assert!(close(got, want), "{what} ({:?}) at ({x},{y}): got {got:?}, want {want:?}", d.depth);
|
||||
// Tiled and whole-canvas renders agree (knockout targets are per tile).
|
||||
let tiled = render_tiled(d, d.bounds(), 3);
|
||||
let whole = render(d, d.bounds());
|
||||
assert!(tiled.px.iter().zip(&whole.px).all(|(a, b)| close(*a, *b)), "{what}: tiles differ from the whole render");
|
||||
}
|
||||
|
||||
/// A 12×8 document at `depth` with a white Background (or none).
|
||||
fn doc(depth: SampleType, background: bool) -> Document {
|
||||
let size = Size::new(12, 8);
|
||||
if background { Document::with_background("t", size, ColorMode::Rgb, depth, Color::WHITE) } else { Document::new("t", size, ColorMode::Rgb, depth) }
|
||||
}
|
||||
|
||||
fn solid(d: &Document, name: &str, r: Rect, c: [f32; 4]) -> Layer {
|
||||
let mut l = Layer::raster(name, d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&d.pixel_format(), c));
|
||||
l
|
||||
}
|
||||
|
||||
fn full(d: &Document) -> Rect {
|
||||
d.bounds()
|
||||
}
|
||||
|
||||
/// The knocking-out layer: blue over the left half (x < 6), fill 0 unless set.
|
||||
fn knocker(d: &Document, k: Knockout) -> Layer {
|
||||
let mut l = solid(d, "knock", Rect::new(0, 0, 6, 8), BLUE);
|
||||
l.advanced.knockout = k;
|
||||
l.fill_opacity = 0.0;
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_knockout_stops_at_the_bottom_of_an_isolated_group() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
let red = solid(&d, "red", full(&d), RED);
|
||||
let mut g = Layer::group("g", vec![red, knocker(&d, Knockout::Shallow)]);
|
||||
g.blend = BlendMode::Normal;
|
||||
d.layers.push(green);
|
||||
d.layers.push(g);
|
||||
// The group's own buffer starts transparent: the layers beneath the group show.
|
||||
check(&d, 2, 2, GREEN, "shallow, fill 0");
|
||||
check(&d, 9, 2, RED, "outside the shape");
|
||||
d.layers[2].children_mut().unwrap()[1].fill_opacity = 0.5;
|
||||
// Blue at 50 % over the knocked-out (transparent) area, then the group over green.
|
||||
check(&d, 2, 2, [0.0, 0.5, 0.5, 1.0], "shallow, fill 50 %");
|
||||
// No knockout: blue at 50 % over red.
|
||||
d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::None;
|
||||
check(&d, 2, 2, [0.5, 0.0, 0.5, 1.0], "no knockout");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_knockout_reaches_the_background_through_groups() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
let red = solid(&d, "red", full(&d), RED);
|
||||
// Pass-through groups let a deep knockout through to the Background.
|
||||
let inner = Layer::group("inner", vec![knocker(&d, Knockout::Deep)]);
|
||||
let outer = Layer::group("outer", vec![red, inner]);
|
||||
d.layers.push(green);
|
||||
d.layers.push(outer);
|
||||
check(&d, 2, 2, WHITE, "deep reveals the Background");
|
||||
check(&d, 9, 2, RED, "outside the shape");
|
||||
// An isolated group stops it at its own (transparent) bottom, like a shallow knockout
|
||||
// (psd-tools knockout-deep-nested): the layers beneath the group show.
|
||||
d.layers[2].blend = BlendMode::Normal;
|
||||
check(&d, 2, 2, GREEN, "deep inside an isolated group");
|
||||
d.layers[2].blend = BlendMode::PassThrough;
|
||||
d.layers[2].children_mut().unwrap()[1].blend = BlendMode::Normal;
|
||||
// The knockout is the inner group's only layer: its buffer stays transparent, red shows.
|
||||
check(&d, 2, 2, RED, "deep inside an isolated inner group");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_shallow_knockout_reaches_the_background_or_transparency() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Shallow));
|
||||
check(&d, 2, 2, WHITE, "top-level shallow");
|
||||
// Layer opacity sets the knockout's strength.
|
||||
d.layers[2].opacity = 0.5;
|
||||
check(&d, 2, 2, [0.5, 1.0, 0.5, 1.0], "half-strength knockout");
|
||||
// Without a Background a deep knockout reveals transparency.
|
||||
let mut nobg = doc(depth, false);
|
||||
let green = solid(&nobg, "green", full(&nobg), GREEN);
|
||||
nobg.layers.push(green);
|
||||
nobg.layers.push(knocker(&nobg, Knockout::Deep));
|
||||
check(&nobg, 2, 2, [0.0; 4], "deep without a Background");
|
||||
check(&nobg, 9, 2, GREEN, "outside the shape");
|
||||
// A hidden Background doesn't show through either.
|
||||
d.layers[0].visible = false;
|
||||
d.layers[2].opacity = 1.0;
|
||||
check(&d, 2, 2, [0.0; 4], "hidden Background");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_knockout_in_a_pass_through_group_reveals_what_is_beneath_it() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
let red = solid(&d, "red", full(&d), RED);
|
||||
let g = Layer::group("pt", vec![red, knocker(&d, Knockout::Shallow)]);
|
||||
assert_eq!(g.blend, BlendMode::PassThrough);
|
||||
d.layers.push(green);
|
||||
d.layers.push(g);
|
||||
check(&d, 2, 2, GREEN, "pass-through shallow");
|
||||
d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::Deep;
|
||||
check(&d, 2, 2, WHITE, "pass-through deep");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_on_a_group_uses_its_composite_shape_and_fill() {
|
||||
// psd-tools knockout-deep-normal: a group with Deep knockout at 50 % fill.
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
let blue = solid(&d, "blue", Rect::new(0, 0, 6, 8), BLUE);
|
||||
let mut g = Layer::group("g", vec![blue]);
|
||||
g.blend = BlendMode::Normal;
|
||||
g.fill_opacity = 0.5;
|
||||
g.advanced.knockout = Knockout::Deep;
|
||||
d.layers.push(green);
|
||||
d.layers.push(g);
|
||||
check(&d, 2, 2, [0.5, 0.5, 1.0, 1.0], "blue at 50 % over the Background");
|
||||
check(&d, 9, 2, GREEN, "outside the group's pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_follows_masks_and_transparency_shapes() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
let mut k = knocker(&d, Knockout::Deep);
|
||||
// A mask hiding x >= 3: only x < 3 knocks out.
|
||||
let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
m.fill_rect(Rect::new(0, 0, 3, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4]));
|
||||
k.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
d.layers.push(k);
|
||||
check(&d, 1, 2, WHITE, "inside the mask");
|
||||
check(&d, 4, 2, GREEN, "masked out");
|
||||
// Transparency Shapes Layer off: the knockout covers the whole layer (masks still apply).
|
||||
d.layers[2].mask = None;
|
||||
d.layers[2].advanced.transparency_shapes = false;
|
||||
check(&d, 9, 2, WHITE, "beyond the pixels, tsly off");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_layer_shallow_knockout_stops_at_the_base() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let base = solid(&d, "base", full(&d), RED);
|
||||
let mut mid = solid(&d, "mid", full(&d), GREEN);
|
||||
mid.clipped = true;
|
||||
let mut k = knocker(&d, Knockout::Shallow);
|
||||
k.clipped = true;
|
||||
d.layers.push(base);
|
||||
d.layers.push(mid);
|
||||
d.layers.push(k);
|
||||
check(&d, 2, 2, RED, "the base shows through the clipped layer above it");
|
||||
check(&d, 9, 2, GREEN, "outside");
|
||||
d.layers[3].advanced.knockout = Knockout::Deep;
|
||||
check(&d, 2, 2, RED, "clipped deep stops at the base too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_clipped_layers_individually() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, false);
|
||||
let gray = solid(&d, "gray", full(&d), GRAY);
|
||||
let mut base = solid(&d, "base", Rect::new(0, 0, 6, 8), RED);
|
||||
base.blend = BlendMode::Screen;
|
||||
let mut clip = solid(&d, "clip", full(&d), BLUE);
|
||||
clip.clipped = true;
|
||||
clip.blend = BlendMode::Multiply;
|
||||
d.layers.push(gray);
|
||||
d.layers.push(base);
|
||||
d.layers.push(clip);
|
||||
// As a group: red × blue = black, screened onto gray = gray.
|
||||
check(&d, 2, 2, GRAY, "as group");
|
||||
// One by one: red screened onto gray = (1, .5, .5), then × blue = (0, 0, .5).
|
||||
d.layers[1].advanced.blend_clipped = false;
|
||||
check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "individually");
|
||||
check(&d, 9, 2, GRAY, "outside the base");
|
||||
// The base's opacity limits the clipped layers too.
|
||||
d.layers[1].opacity = 0.5;
|
||||
// Base at 50 %: (.75, .5, .5); the multiply then mixes in at 50 %: (.375, .25, .5).
|
||||
check(&d, 2, 2, [0.375, 0.25, 0.5, 1.0], "half-opacity base");
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(c: [f32; 3]) -> Effect {
|
||||
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(c[0], c[1], c[2]) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE);
|
||||
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
|
||||
l.fill_opacity = 0.0;
|
||||
d.layers.push(l);
|
||||
check(&d, 2, 2, RED, "default: overlays ignore fill");
|
||||
d.layers[1].advanced.blend_interior = true;
|
||||
check(&d, 2, 2, WHITE, "as group at fill 0");
|
||||
d.layers[1].fill_opacity = 0.5;
|
||||
check(&d, 2, 2, [1.0, 0.5, 0.5, 1.0], "as group at fill 50 %");
|
||||
d.layers[1].advanced.blend_interior = false;
|
||||
check(&d, 2, 2, RED, "default at fill 50 %");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_shapes_off_spreads_effects_over_the_whole_layer() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE);
|
||||
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
|
||||
d.layers.push(l);
|
||||
check(&d, 9, 2, WHITE, "default: the overlay follows the pixels");
|
||||
d.layers[1].advanced.transparency_shapes = false;
|
||||
check(&d, 9, 2, RED, "tsly off: the overlay covers the layer");
|
||||
check(&d, 2, 2, RED, "inside");
|
||||
// At fill 0 the content's transparency no longer matters.
|
||||
d.layers[1].fill_opacity = 0.0;
|
||||
check(&d, 9, 2, RED, "tsly off, fill 0");
|
||||
}
|
||||
}
|
||||
|
||||
fn inside_stroke() -> Effect {
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 2.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_mask_hides_effects() {
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let mut l = solid(&d, "l", full(&d), BLUE);
|
||||
l.effects.items = vec![inside_stroke()];
|
||||
// The mask shows x < 8.
|
||||
let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
m.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4]));
|
||||
l.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
d.layers.push(l);
|
||||
// Default: the stroke follows the masked shape, so it runs along the mask's edge.
|
||||
check(&d, 7, 4, RED, "stroke along the mask edge");
|
||||
check(&d, 9, 4, WHITE, "masked out");
|
||||
check(&d, 4, 4, BLUE, "interior");
|
||||
// Layer Mask Hides Effects: the stroke is built from the whole layer (canvas edges only)
|
||||
// and the mask then hides the result.
|
||||
d.layers[1].advanced.layer_mask_hides_effects = true;
|
||||
check(&d, 7, 4, BLUE, "no stroke at the mask edge");
|
||||
check(&d, 4, 0, RED, "the canvas-edge stroke stays");
|
||||
check(&d, 9, 4, WHITE, "still masked out");
|
||||
check(&d, 11, 0, WHITE, "the stroke beyond the mask is hidden");
|
||||
// The vector-mask switch alone changes nothing for a pixel mask.
|
||||
d.layers[1].advanced.layer_mask_hides_effects = false;
|
||||
d.layers[1].advanced.vector_mask_hides_effects = true;
|
||||
check(&d, 7, 4, RED, "vmgm without a vector mask");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_mask_hides_effects() {
|
||||
use photocraft_doc::{Path, Subpath, VectorMask};
|
||||
for depth in DEPTHS {
|
||||
let mut d = doc(depth, true);
|
||||
let mut l = solid(&d, "l", full(&d), BLUE);
|
||||
l.effects.items = vec![inside_stroke()];
|
||||
let path = Path::new(vec![Subpath::polygon(&[(-4.0, -4.0), (8.0, -4.0), (8.0, 12.0), (-4.0, 12.0)])]);
|
||||
l.vector_mask = Some(VectorMask::new(path));
|
||||
d.layers.push(l);
|
||||
check(&d, 7, 4, RED, "stroke along the vector mask");
|
||||
d.layers[1].advanced.vector_mask_hides_effects = true;
|
||||
check(&d, 7, 4, BLUE, "vector mask hides the stroke");
|
||||
check(&d, 9, 4, WHITE, "masked out");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_take_the_plain_paths() {
|
||||
let d = doc(SampleType::U8, true);
|
||||
let mut l = solid(&d, "l", full(&d), BLUE);
|
||||
assert!(!advanced_active(&l, &[]));
|
||||
l.advanced.knockout = Knockout::Shallow;
|
||||
assert!(advanced_active(&l, &[]));
|
||||
l.advanced.knockout = Knockout::None;
|
||||
// Switches that change nothing for this layer (no effects, no clipped layers) stay inactive.
|
||||
l.advanced.blend_interior = true;
|
||||
l.advanced.layer_mask_hides_effects = true;
|
||||
l.advanced.blend_clipped = false;
|
||||
assert!(!advanced_active(&l, &[]));
|
||||
let mut c = solid(&d, "c", full(&d), RED);
|
||||
c.clipped = true;
|
||||
assert!(advanced_active(&l, std::slice::from_ref(&c)));
|
||||
c.visible = false;
|
||||
assert!(!advanced_active(&l, std::slice::from_ref(&c)));
|
||||
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
|
||||
assert!(advanced_active(&l, &[]), "blend interior with effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knockout_background_does_not_recurse() {
|
||||
// A Background carrying a knockout (only reachable through a file) renders without looping.
|
||||
let mut d = doc(SampleType::U8, true);
|
||||
d.layers[0].advanced.knockout = Knockout::Deep;
|
||||
d.layers[0].fill_opacity = 0.0;
|
||||
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
|
||||
assert!(got[3] <= 1.0);
|
||||
}
|
||||
@@ -1206,13 +1206,18 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
|
||||
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
|
||||
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
|
||||
let shape = if maps.outline {
|
||||
// Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were
|
||||
// built), and the content's own transparency acts like fill opacity within it.
|
||||
let shapeless = !layer.advanced.transparency_shapes;
|
||||
let shape = if shapeless {
|
||||
maps.crop(&maps.shape, big, 0.0)
|
||||
} else if maps.outline {
|
||||
let o = maps.crop(&maps.shape, big, 0.0);
|
||||
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
|
||||
} else {
|
||||
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
|
||||
};
|
||||
let relative = maps.outline || vstroke.is_some();
|
||||
let relative = shapeless || maps.outline || vstroke.is_some();
|
||||
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
|
||||
// Layer bounds (gradients aligned with the layer use the whole layer,
|
||||
// independent of the render rect).
|
||||
@@ -1259,6 +1264,10 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
// applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and
|
||||
// keeps its alpha, as in Photoshop.
|
||||
let fill = layer.fill_opacity;
|
||||
// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are
|
||||
// combined with the content first, and fill opacity applies to the combination.
|
||||
let interior_group = layer.advanced.blend_interior && fill < 1.0;
|
||||
let content_fill = if interior_group { 1.0 } else { fill };
|
||||
let inside = |a: f32| a > INSIDE_EPS;
|
||||
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
|
||||
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
|
||||
@@ -1266,9 +1275,9 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
if !inside(a) {
|
||||
0.0
|
||||
} else if relative {
|
||||
fill * (kmask(i) * p[3] / a).min(1.0)
|
||||
content_fill * (kmask(i) * p[3] / a).min(1.0)
|
||||
} else {
|
||||
fill
|
||||
content_fill
|
||||
}
|
||||
};
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
|
||||
@@ -1303,6 +1312,11 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns);
|
||||
}
|
||||
}
|
||||
if interior_group {
|
||||
for p in &mut lay.px {
|
||||
p[3] *= fill.max(0.0);
|
||||
}
|
||||
}
|
||||
for (i, e) in rev() {
|
||||
if let Effect::InnerShadow(s) = e {
|
||||
let m = rel(fx(i, 0));
|
||||
|
||||
+104
-54
@@ -17,6 +17,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod adjust;
|
||||
pub mod advanced;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
@@ -34,6 +35,8 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Rgba8Image, Surface};
|
||||
use psblend as blend;
|
||||
|
||||
pub use advanced::advanced_active;
|
||||
|
||||
/// Straight-alpha RGBA float buffer covering a rectangle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Buffer {
|
||||
@@ -105,7 +108,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
return photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut buf, cx);
|
||||
composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
buf
|
||||
});
|
||||
@@ -122,7 +125,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut b = multichannel::backdrop(doc, t);
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
composite_stack(&doc.layers, &mut b, cx);
|
||||
composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT);
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
b
|
||||
})
|
||||
@@ -257,7 +260,7 @@ pub fn flatten(doc: &Document) -> Buffer {
|
||||
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
composite_stack(
|
||||
composite_stack_at(
|
||||
std::slice::from_ref(layer),
|
||||
&mut buf,
|
||||
&Ctx {
|
||||
@@ -269,7 +272,9 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
depth: photocraft_color::SampleType::F32,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
doc: None,
|
||||
},
|
||||
advanced::Scope::ROOT,
|
||||
);
|
||||
buf
|
||||
}
|
||||
@@ -439,11 +444,14 @@ struct Ctx<'a> {
|
||||
/// Effect maps used by this render, by cache key: built before the parallel tiles (see
|
||||
/// [`prepare_effects`]) and held for the whole render, so eviction can't force a rebuild.
|
||||
fx_maps: std::sync::Mutex<std::collections::HashMap<u64, std::sync::Arc<effects::FxMaps>>>,
|
||||
/// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer.
|
||||
doc: Option<&'a Document>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
fn for_doc(doc: &Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
|
||||
fn for_doc(doc: &'a Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
|
||||
Self {
|
||||
doc: Some(doc),
|
||||
canvas: doc.bounds(),
|
||||
transfer: adjust::Transfer::for_document(doc.mode, doc.depth),
|
||||
light: doc.global_light,
|
||||
@@ -467,7 +475,7 @@ const PREPARE_DEPTH: u32 = 64;
|
||||
fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut dyn FnMut()) + Sync) {
|
||||
let mut todo = Vec::new();
|
||||
effect_layers(layers, rect, 0, &mut todo);
|
||||
let build = |l: &&Layer| {
|
||||
let build = |l: &std::borrow::Cow<Layer>| {
|
||||
setting(&mut || {
|
||||
let _ = effect_maps(l, cx);
|
||||
});
|
||||
@@ -482,7 +490,7 @@ fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut
|
||||
}
|
||||
|
||||
/// The visible layers of `layers` (groups included) with effects that can reach `rect`.
|
||||
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<&'l Layer>) {
|
||||
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<std::borrow::Cow<'l, Layer>>) {
|
||||
let mut base_visible = true;
|
||||
for l in layers {
|
||||
if !l.clipped {
|
||||
@@ -493,7 +501,8 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<
|
||||
continue;
|
||||
}
|
||||
if effects::has_effects(l) && !empty_in(l, rect) {
|
||||
out.push(l);
|
||||
// A mask that hides the effects is left out of the shape they are built from.
|
||||
out.push(advanced::effects_source(l));
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content
|
||||
&& depth < PREPARE_DEPTH
|
||||
@@ -503,8 +512,16 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite a sibling list (bottom→top) onto `backdrop`.
|
||||
/// Composite an isolated group's sibling list (bottom→top) onto `backdrop` (its own buffer).
|
||||
fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
composite_stack_at(layers, backdrop, cx, advanced::Scope::ISOLATED);
|
||||
}
|
||||
|
||||
/// Composite a sibling list (bottom→top) onto `backdrop`; `scope` says where the stack sits (see
|
||||
/// [`advanced::Scope`]: it decides what knockouts reveal).
|
||||
fn composite_stack_at(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
// Shallow knockouts stop at the bottom of this stack: the backdrop it starts from.
|
||||
let floor = (!scope.root && advanced::needs_floor(layers)).then(|| backdrop.clone());
|
||||
let mut i = 0;
|
||||
while i < layers.len() {
|
||||
let base = &layers[i];
|
||||
@@ -515,7 +532,8 @@ fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
}
|
||||
let clipped = &layers[i + 1..j];
|
||||
if base.visible {
|
||||
composite_layer(base, clipped, backdrop, cx);
|
||||
let ko = advanced::knockout_target(base, floor.as_ref(), backdrop.rect, cx, scope);
|
||||
composite_layer(base, clipped, backdrop, cx, ko.as_deref(), scope);
|
||||
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, adjustment_quantum(cx.depth)) {
|
||||
quantize(backdrop, q);
|
||||
}
|
||||
@@ -559,6 +577,10 @@ fn dissolve_noise(x: i32, y: i32) -> f32 {
|
||||
/// Bounds of a layer's own pixels (union over group children; the canvas
|
||||
/// for fill layers; an artboard's board).
|
||||
pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
// Transparency Shapes Layer off: effects and knockout cover the whole layer (the canvas).
|
||||
if advanced::shapeless(layer) {
|
||||
return canvas;
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect),
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
@@ -603,6 +625,11 @@ pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> {
|
||||
/// path where the fill fades out (psd-tools stroke-effects).
|
||||
fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
// Transparency Shapes Layer off: the effects take the whole layer as their shape (its masks
|
||||
// still apply).
|
||||
if !layer.advanced.transparency_shapes {
|
||||
return mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]);
|
||||
}
|
||||
let Some(path) = effect_outline(layer).filter(|_| n > 0) else {
|
||||
return render_content(layer, rect, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
|
||||
};
|
||||
@@ -994,12 +1021,20 @@ fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
|
||||
}
|
||||
|
||||
/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions
|
||||
/// and Blend If.
|
||||
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
/// and Blend If, and knocking out to `ko` when it has a knockout (see [`advanced`]).
|
||||
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: advanced::Scope) {
|
||||
// Blend Clipped Layers as Group off: the base alone, then each clipped layer in its own mode.
|
||||
if advanced::clips_individually(layer, clipped) {
|
||||
advanced::composite_clipped_individually(layer, clipped, backdrop, cx, ko, scope);
|
||||
return;
|
||||
}
|
||||
let w = channel_weights(layer, cx.mode);
|
||||
let blend_if = blend_if_active(layer, cx.mode);
|
||||
let before = (w.is_some() || blend_if).then(|| backdrop.clone());
|
||||
composite_layer_any(layer, clipped, backdrop, cx);
|
||||
match ko {
|
||||
Some(target) => advanced::knockout(layer, backdrop, target, false, cx, |b| composite_layer_any(layer, clipped, b, cx, scope)),
|
||||
None => composite_layer_any(layer, clipped, backdrop, cx, scope),
|
||||
}
|
||||
if let Some(before) = &before {
|
||||
if let Some(w) = w {
|
||||
restore_channels(backdrop, before, w);
|
||||
@@ -1010,19 +1045,19 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx:
|
||||
}
|
||||
}
|
||||
|
||||
fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& let Some(ab) = &g.artboard
|
||||
{
|
||||
composite_artboard(layer, ab, clipped, backdrop, cx);
|
||||
composite_artboard(layer, ab, clipped, backdrop, cx, scope);
|
||||
return;
|
||||
}
|
||||
composite_layer_plain(layer, clipped, backdrop, cx);
|
||||
composite_layer_plain(layer, clipped, backdrop, cx, scope);
|
||||
}
|
||||
|
||||
/// An artboard: its background and the group, composited only inside the board (contents and
|
||||
/// effects outside it are clipped away; the backdrop there is untouched).
|
||||
fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
let board = ab.rect.intersect(&backdrop.rect);
|
||||
if board.is_empty() {
|
||||
return;
|
||||
@@ -1038,7 +1073,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
|
||||
if let Some(bg) = ab.background.rgba() {
|
||||
blend_into(&mut sub, &Buffer::filled(board, bg), BlendMode::Normal, 1.0);
|
||||
}
|
||||
composite_layer_plain(layer, clipped, &mut sub, cx);
|
||||
composite_layer_plain(layer, clipped, &mut sub, cx, scope);
|
||||
for y in board.y0..board.y1 {
|
||||
let o = row0(y);
|
||||
let so = (y - board.y0) as usize * bw;
|
||||
@@ -1046,7 +1081,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
|
||||
}
|
||||
}
|
||||
|
||||
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
let rect = backdrop.rect;
|
||||
if empty_in(layer, rect) {
|
||||
return;
|
||||
@@ -1065,7 +1100,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
let has_clipped = clipped.iter().any(|c| c.visible);
|
||||
// Children can draw directly unless mixing or clipping needs the original backdrop.
|
||||
let before = (needs_mix || has_clipped).then(|| backdrop.clone());
|
||||
composite_stack(&g.children, backdrop, cx);
|
||||
composite_stack_at(&g.children, backdrop, cx, scope.pass_through());
|
||||
if needs_mix && let Some(before) = &before {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() {
|
||||
@@ -1090,9 +1125,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
// otherwise (matches psd-tools clipping-mask3/4/5).
|
||||
if has_clipped && let (Some(before), Some(iso)) = (before, render_content(layer, rect, cx)) {
|
||||
let mut clipped_iso = iso.clone();
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut clipped_iso, cx);
|
||||
}
|
||||
advanced::composite_clipped(clipped, &mut clipped_iso, cx);
|
||||
let mut without = before.clone();
|
||||
blend_into(&mut without, &iso, BlendMode::Normal, opacity);
|
||||
let mut with = before;
|
||||
@@ -1117,9 +1150,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut adjusted, cx);
|
||||
}
|
||||
advanced::composite_clipped(clipped, &mut adjusted, cx);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
@@ -1139,36 +1170,19 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
}
|
||||
|
||||
if effects::has_effects(layer) {
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
// Layer / Vector Mask Hides Effects: the effects are built without that mask, which then
|
||||
// hides the finished layer and effects.
|
||||
let src = advanced::effects_source(layer);
|
||||
let hide = advanced::hiding_mask(layer, rect, cx);
|
||||
let before = hide.as_ref().map(|_| backdrop.clone());
|
||||
composite_layer_effects(&src, clipped, backdrop, cx);
|
||||
if let (Some(m), Some(b)) = (&hide, &before) {
|
||||
advanced::apply_hiding_mask(backdrop, b, m);
|
||||
}
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
backdrop,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
|
||||
p[3] *= mask_k(&mask, i);
|
||||
@@ -1177,12 +1191,37 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
return;
|
||||
}
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer));
|
||||
}
|
||||
|
||||
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||
fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
|
||||
let rect = backdrop.rect;
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
backdrop,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
}
|
||||
|
||||
/// The coverage-mixing gamma of a layer: the text blending gamma
|
||||
/// ([`psblend::set_text_gamma`], Photoshop's default 1.45) for type layers, else 1.
|
||||
pub fn text_gamma(layer: &Layer) -> f32 {
|
||||
@@ -1443,6 +1482,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
// Effects of a clipped layer are clipped to the base too: render
|
||||
// them over the base (treated as opaque) and keep the base's alpha.
|
||||
// The full-region effect maps already capture their neighbourhoods.
|
||||
// A mask that hides the effects applies to the finished result instead.
|
||||
let hide = advanced::hiding_mask(layer, rect, cx);
|
||||
let src = advanced::effects_source(layer);
|
||||
let layer = src.as_ref();
|
||||
let (content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
@@ -1462,6 +1505,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
if let Some(m) = &hide {
|
||||
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
advanced::apply_hiding_mask(&mut opaque, &plain, m);
|
||||
}
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
*p = [o[0], o[1], o[2], p[3]];
|
||||
@@ -1515,3 +1562,6 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f
|
||||
mod stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod advanced_tests;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Layer Style › Blending Options › Advanced Blending: knockout and the "as group" / "hides
|
||||
//! effects" switches. Fill opacity ([`crate::Layer::fill_opacity`]), the channel restrictions
|
||||
//! ([`crate::Layer::excluded_channels`]) and Blend If ([`crate::Layer::blend_if`]) live on the
|
||||
//! layer itself; this struct holds the rest of the section.
|
||||
//!
|
||||
//! Defaults are Photoshop's: no knockout, interior effects blended separately, clipped layers
|
||||
//! blended as a group, transparency shaping the layer's effects and knockout, and masks applied
|
||||
//! before the effects are built (so the effects follow the masked shape).
|
||||
//!
|
||||
//! PSD: `knko` (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`, each a
|
||||
//! one-byte flag followed by three bytes of padding (Adobe Photoshop File Format specification,
|
||||
//! "Additional Layer Information").
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Blending Options › Advanced Blending › Knockout: which layers beneath this one its shape
|
||||
/// punches through. Fill opacity controls the strength (0 % fill shows the knocked-out area bare).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Knockout {
|
||||
/// The layer blends with everything beneath it (the default).
|
||||
#[default]
|
||||
None,
|
||||
/// Knocks out to the bottom of the enclosing layer group or clipping group (for a layer at
|
||||
/// the top level: to the Background layer, or to transparency without one).
|
||||
Shallow,
|
||||
/// Knocks out to the Background layer (to transparency without one), through every group.
|
||||
Deep,
|
||||
}
|
||||
|
||||
impl Knockout {
|
||||
/// The PSD `knko` value (0 none, 1 shallow, 2 deep).
|
||||
pub fn to_psd(self) -> u8 {
|
||||
match self {
|
||||
Knockout::None => 0,
|
||||
Knockout::Shallow => 1,
|
||||
Knockout::Deep => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// From a PSD `knko` value; values above 2 (not written by Photoshop) read as deep.
|
||||
pub fn from_psd(v: u8) -> Self {
|
||||
match v {
|
||||
0 => Knockout::None,
|
||||
1 => Knockout::Shallow,
|
||||
_ => Knockout::Deep,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter / UI name (`none`, `shallow`, `deep`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Knockout::None => "none",
|
||||
Knockout::Shallow => "shallow",
|
||||
Knockout::Deep => "deep",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Knockout::name`] (case-insensitive).
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"none" => Some(Knockout::None),
|
||||
"shallow" => Some(Knockout::Shallow),
|
||||
"deep" => Some(Knockout::Deep),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Advanced Blending switches of a layer (see the module docs). `Default` is Photoshop's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AdvancedBlending {
|
||||
/// Knockout: None / Shallow / Deep. PSD `knko`.
|
||||
pub knockout: Knockout,
|
||||
/// Blend Interior Effects as Group: Inner Glow, Satin and the Color, Gradient and Pattern
|
||||
/// Overlays are combined with the layer's content before its fill opacity and blend mode
|
||||
/// apply (off: fill opacity leaves them alone). PSD `infx`, default off.
|
||||
pub blend_interior: bool,
|
||||
/// Blend Clipped Layers as Group: the clipping group composites as a whole in the base
|
||||
/// layer's blend mode (off: each clipped layer blends with the layers beneath the base in its
|
||||
/// own mode, inside the base's shape). PSD `clbl`, default on.
|
||||
pub blend_clipped: bool,
|
||||
/// Transparency Shapes Layer: the layer's transparency limits its effects and knockout (off:
|
||||
/// they cover the whole layer). PSD `tsly`, default on.
|
||||
pub transparency_shapes: bool,
|
||||
/// Layer Mask Hides Effects: the layer mask hides the effects instead of shaping them. PSD
|
||||
/// `lmgm` ("layer mask as global mask"), default off.
|
||||
pub layer_mask_hides_effects: bool,
|
||||
/// Vector Mask Hides Effects: the vector mask hides the effects instead of shaping them. PSD
|
||||
/// `vmgm`, default off.
|
||||
pub vector_mask_hides_effects: bool,
|
||||
}
|
||||
|
||||
impl Default for AdvancedBlending {
|
||||
fn default() -> Self {
|
||||
AdvancedBlending {
|
||||
knockout: Knockout::None,
|
||||
blend_interior: false,
|
||||
blend_clipped: true,
|
||||
transparency_shapes: true,
|
||||
layer_mask_hides_effects: false,
|
||||
vector_mask_hides_effects: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdvancedBlending {
|
||||
/// Whether every switch is at Photoshop's default.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_photoshop() {
|
||||
let a = AdvancedBlending::default();
|
||||
assert_eq!(a.knockout, Knockout::None);
|
||||
assert!(!a.blend_interior);
|
||||
assert!(a.blend_clipped);
|
||||
assert!(a.transparency_shapes);
|
||||
assert!(!a.layer_mask_hides_effects);
|
||||
assert!(!a.vector_mask_hides_effects);
|
||||
assert!(a.is_default());
|
||||
assert!(!AdvancedBlending { blend_clipped: false, ..a }.is_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_psd_values_and_names() {
|
||||
for k in [Knockout::None, Knockout::Shallow, Knockout::Deep] {
|
||||
assert_eq!(Knockout::from_psd(k.to_psd()), k);
|
||||
assert_eq!(Knockout::from_name(k.name()), Some(k));
|
||||
}
|
||||
assert_eq!(Knockout::from_psd(7), Knockout::Deep);
|
||||
assert_eq!(Knockout::from_name("DEEP"), Some(Knockout::Deep));
|
||||
assert_eq!(Knockout::from_name("medium"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_keeps_photoshop_defaults() {
|
||||
let a: AdvancedBlending = serde_json::from_str(r#"{"knockout":"deep"}"#).unwrap();
|
||||
assert_eq!(a, AdvancedBlending { knockout: Knockout::Deep, ..Default::default() });
|
||||
let b: AdvancedBlending = serde_json::from_str("{}").unwrap();
|
||||
assert!(b.is_default());
|
||||
let json = serde_json::to_string(&AdvancedBlending { layer_mask_hides_effects: true, ..Default::default() }).unwrap();
|
||||
assert!(json.contains("\"layerMaskHidesEffects\":true"), "{json}");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod adjust;
|
||||
pub mod advanced;
|
||||
pub mod analysis;
|
||||
pub mod blend_if;
|
||||
pub mod comps;
|
||||
@@ -26,6 +27,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub use adjust::Adjustment;
|
||||
pub use advanced::{AdvancedBlending, Knockout};
|
||||
pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler};
|
||||
pub use blend_if::{BlendIf, BlendRange};
|
||||
pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp};
|
||||
@@ -495,6 +497,9 @@ pub struct Layer {
|
||||
/// outside which the layer's pixels are hidden. Default = everything blends. PSD layer-record
|
||||
/// blending ranges.
|
||||
pub blend_if: BlendIf,
|
||||
/// Blending Options › Advanced Blending: knockout, blend interior effects / clipped layers as
|
||||
/// group, transparency shapes layer, layer / vector mask hides effects. Default = Photoshop's.
|
||||
pub advanced: AdvancedBlending,
|
||||
/// Layer › Video Layers frame stack (None for a normal layer).
|
||||
pub video: Option<VideoData>,
|
||||
}
|
||||
@@ -521,6 +526,7 @@ impl Layer {
|
||||
link_group: None,
|
||||
excluded_channels: 0,
|
||||
blend_if: BlendIf::default(),
|
||||
advanced: AdvancedBlending::default(),
|
||||
video: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?;
|
||||
let style = (l.effects.clone(), l.blend, l.fill_opacity);
|
||||
let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced);
|
||||
s.style_clipboard = Some(style);
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
@@ -680,12 +680,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let (fx, blend, fill) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
|
||||
let (fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
|
||||
s.edit("Paste Layer Style", |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.effects = fx;
|
||||
l.blend = blend;
|
||||
l.fill_opacity = fill;
|
||||
l.advanced = advanced;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -112,6 +112,10 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
|
||||
if l.excluded_channels != 0 {
|
||||
v["channels"] = json!((0..4).map(|i| l.excluded_channels & (1 << i) == 0).collect::<Vec<_>>());
|
||||
}
|
||||
// Blending Options › Advanced Blending switches (only when one differs from Photoshop's default).
|
||||
if !l.advanced.is_default() {
|
||||
v["advancedBlending"] = serde_json::to_value(l.advanced).unwrap_or(Value::Null);
|
||||
}
|
||||
// Blending Options › Blend If (only when set): range index (0 = Gray, then the mode's
|
||||
// channels) with This Layer / Underlying Layer as [blackLo, blackHi, whiteLo, whiteHi].
|
||||
if !l.blend_if.is_default() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use photocraft_algo::selection::Region;
|
||||
use photocraft_color::{BlendMode, ColorMode};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
@@ -396,6 +396,7 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
Some(v) => Some(blend_if_entries(v, mode)?),
|
||||
};
|
||||
let adv = advanced_params(p, mode)?;
|
||||
s.edit("Blending Options", |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(b) = blend {
|
||||
@@ -421,11 +422,92 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
adv.apply(l);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The Advanced Blending parameters of `layer.layerStyle.blendingOptions`; `None` = keep.
|
||||
#[derive(Default)]
|
||||
struct AdvancedParams {
|
||||
knockout: Option<Knockout>,
|
||||
blend_interior: Option<bool>,
|
||||
blend_clipped: Option<bool>,
|
||||
transparency_shapes: Option<bool>,
|
||||
layer_mask_hides: Option<bool>,
|
||||
vector_mask_hides: Option<bool>,
|
||||
/// Excluded-channel bit mask (from `channels`).
|
||||
excluded: Option<u32>,
|
||||
}
|
||||
|
||||
impl AdvancedParams {
|
||||
fn apply(&self, l: &mut Layer) {
|
||||
let a = &mut l.advanced;
|
||||
if let Some(k) = self.knockout {
|
||||
a.knockout = k;
|
||||
}
|
||||
let set = |dst: &mut bool, v: Option<bool>| {
|
||||
if let Some(v) = v {
|
||||
*dst = v;
|
||||
}
|
||||
};
|
||||
set(&mut a.blend_interior, self.blend_interior);
|
||||
set(&mut a.blend_clipped, self.blend_clipped);
|
||||
set(&mut a.transparency_shapes, self.transparency_shapes);
|
||||
set(&mut a.layer_mask_hides_effects, self.layer_mask_hides);
|
||||
set(&mut a.vector_mask_hides_effects, self.vector_mask_hides);
|
||||
if let Some(x) = self.excluded {
|
||||
l.excluded_channels = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and validate the Advanced Blending keys (wrong types and unknown values are errors).
|
||||
fn advanced_params(p: &Value, mode: ColorMode) -> Result<AdvancedParams> {
|
||||
const CMD: &str = "layer.layerStyle.blendingOptions";
|
||||
let flag = |key: &str| -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(v) => Err(bad(CMD, format!("{key}: expected true or false (got {v})"))),
|
||||
}
|
||||
};
|
||||
let knockout = match p.get("knockout") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(Knockout::from_name(s).ok_or_else(|| bad(CMD, format!("knockout: expected none, shallow or deep (got `{s}`)")))?),
|
||||
Some(v) => return Err(bad(CMD, format!("knockout: expected \"none\", \"shallow\" or \"deep\" (got {v})"))),
|
||||
};
|
||||
let excluded = match p.get("channels") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) => {
|
||||
let n = mode.color_channels().max(1);
|
||||
if a.len() > n {
|
||||
return Err(bad(CMD, format!("channels: a {mode:?} document has {n} colour channel(s), got {}", a.len())));
|
||||
}
|
||||
let mut mask = 0u32;
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
match v {
|
||||
Value::Bool(true) => {}
|
||||
Value::Bool(false) => mask |= 1 << i,
|
||||
other => return Err(bad(CMD, format!("channels[{i}]: expected true or false (got {other})"))),
|
||||
}
|
||||
}
|
||||
Some(mask)
|
||||
}
|
||||
Some(v) => return Err(bad(CMD, format!("channels: expected a list of true/false per colour channel (got {v})"))),
|
||||
};
|
||||
Ok(AdvancedParams {
|
||||
knockout,
|
||||
blend_interior: flag("blendInteriorEffectsAsGroup")?,
|
||||
blend_clipped: flag("blendClippedLayersAsGroup")?,
|
||||
transparency_shapes: flag("transparencyShapesLayer")?,
|
||||
layer_mask_hides: flag("layerMaskHidesEffects")?,
|
||||
vector_mask_hides: flag("vectorMaskHidesEffects")?,
|
||||
excluded,
|
||||
})
|
||||
}
|
||||
|
||||
/// One `blendIf` entry: (range index, This Layer, Underlying Layer); `None` = keep.
|
||||
type BlendIfEntry = (usize, Option<BlendRange>, Option<BlendRange>);
|
||||
|
||||
@@ -761,7 +843,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"layer.layerStyle.blendingOptions",
|
||||
"Blending Options…",
|
||||
["Layer", "Layer Style"],
|
||||
r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?,"blendIf":{"channel":"gray|red|green|blue|cyan|…"|index="gray","thisLayer":[black,white]|[blackLo,blackHi,whiteLo,whiteHi]?,"underlying":[…]?}|[{…},…]|null?} (Blend If values 0..255; split points fade; null resets)"##,
|
||||
r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?,"blendIf":{"channel":"gray|red|green|blue|cyan|…"|index="gray","thisLayer":[black,white]|[blackLo,blackHi,whiteLo,whiteHi]?,"underlying":[…]?}|[{…},…]|null?,"channels":[bool,…]?,"knockout":"none|shallow|deep"?,"blendInteriorEffectsAsGroup":bool?,"blendClippedLayersAsGroup":bool?,"transparencyShapesLayer":bool?,"layerMaskHidesEffects":bool?,"vectorMaskHidesEffects":bool?} (Blend If values 0..255; split points fade; null resets. Advanced Blending: channels = which colour channels blend (R G B / C M Y K / L a b); Photoshop's defaults are knockout none, interior effects off, clipped layers as group on, transparency shapes on, masks hide effects off)"##,
|
||||
has_layer,
|
||||
blending_options
|
||||
),
|
||||
|
||||
@@ -202,6 +202,101 @@ fn blend_if_rejects_bad_params() {
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_options_advanced_blending() {
|
||||
use photocraft_doc::{AdvancedBlending, Knockout};
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
paint(&mut s, |x, _| if x < 20 { [0.0, 0.0, 1.0, 1.0] } else { [0.0; 4] });
|
||||
assert!(active(&s).advanced.is_default());
|
||||
s.execute(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({
|
||||
"knockout": "deep",
|
||||
"fillOpacity": 0,
|
||||
"blendInteriorEffectsAsGroup": true,
|
||||
"blendClippedLayersAsGroup": false,
|
||||
"transparencyShapesLayer": false,
|
||||
"layerMaskHidesEffects": true,
|
||||
"vectorMaskHidesEffects": true,
|
||||
"channels": [true, false, true],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let l = active(&s);
|
||||
assert_eq!(
|
||||
l.advanced,
|
||||
AdvancedBlending {
|
||||
knockout: Knockout::Deep,
|
||||
blend_interior: true,
|
||||
blend_clipped: false,
|
||||
transparency_shapes: false,
|
||||
layer_mask_hides_effects: true,
|
||||
vector_mask_hides_effects: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(l.excluded_channels, 0b010);
|
||||
// A deep knockout at fill 0 with no Background reveals transparency where the layer has
|
||||
// pixels.
|
||||
let p = photocraft_compose::flatten(doc(&s)).get(5, 5);
|
||||
assert!(p[3] < 2.0 / 255.0, "{depth}: {p:?}");
|
||||
// Inspect reports the switches; omitted keys keep their values; one undo step each.
|
||||
let info = crate::inspect::layer(active(&s));
|
||||
assert_eq!(info["advancedBlending"]["knockout"], json!("deep"), "{info}");
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "Shallow"})).unwrap();
|
||||
assert_eq!(active(&s).advanced.knockout, Knockout::Shallow);
|
||||
assert!(!active(&s).advanced.blend_clipped);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(active(&s).advanced.knockout, Knockout::Deep);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(active(&s).advanced.is_default());
|
||||
assert_eq!(active(&s).excluded_channels, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_options_advanced_rejects_bad_params() {
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, |_, _| [1.0; 4]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for bad in [
|
||||
json!({"knockout": "medium"}),
|
||||
json!({"knockout": 2}),
|
||||
json!({"knockout": true}),
|
||||
json!({"blendInteriorEffectsAsGroup": "yes"}),
|
||||
json!({"blendClippedLayersAsGroup": 1}),
|
||||
json!({"transparencyShapesLayer": []}),
|
||||
json!({"layerMaskHidesEffects": {}}),
|
||||
json!({"vectorMaskHidesEffects": 0.5}),
|
||||
json!({"channels": [true, true, true, true, true]}),
|
||||
json!({"channels": [1, 0, 1]}),
|
||||
json!({"channels": "rgb"}),
|
||||
// A bad key fails the whole call: nothing else is applied.
|
||||
json!({"fillOpacity": 10, "knockout": "nope"}),
|
||||
] {
|
||||
assert!(s.execute("layer.layerStyle.blendingOptions", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(active(&s).advanced.is_default());
|
||||
assert_eq!(active(&s).fill_opacity, 1.0);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
// `null` keeps the current value.
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": null, "channels": null})).unwrap();
|
||||
assert!(active(&s).advanced.is_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_layer_style_carries_advanced_blending() {
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, |_, _| [1.0; 4]);
|
||||
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "shallow", "blendClippedLayersAsGroup": false})).unwrap();
|
||||
let a = active(&s).advanced;
|
||||
s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(active(&s).advanced.is_default());
|
||||
s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
|
||||
assert_eq!(active(&s).advanced, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_channel_names_follow_the_mode() {
|
||||
let mut s = session(8, "cmyk");
|
||||
|
||||
@@ -477,6 +477,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool {
|
||||
link_group: _,
|
||||
excluded_channels,
|
||||
blend_if,
|
||||
advanced,
|
||||
video: _,
|
||||
} = a;
|
||||
*id == b.id
|
||||
@@ -490,6 +491,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool {
|
||||
&& *effects == b.effects
|
||||
&& *excluded_channels == b.excluded_channels
|
||||
&& *blend_if == b.blend_if
|
||||
&& *advanced == b.advanced
|
||||
&& match (content, &b.content) {
|
||||
(LayerContent::Raster(x), LayerContent::Raster(y)) => surfaces_equal(x, y),
|
||||
// Children are walked separately; only the group's own clipping frame counts.
|
||||
|
||||
@@ -272,8 +272,9 @@ pub struct Session {
|
||||
coalesce_request: Option<String>,
|
||||
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
|
||||
pub clipboard: Option<edit_cmds::Clip>,
|
||||
/// Layer › Layer Style › Copy Layer Style: effects, blend mode and fill opacity.
|
||||
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32)>,
|
||||
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
|
||||
/// Blending switches.
|
||||
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>,
|
||||
/// Shape path context menu: copied vector fill and stroke styles.
|
||||
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
|
||||
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
|
||||
|
||||
@@ -197,6 +197,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM {
|
||||
link_group: l.link_group,
|
||||
excluded_channels: l.excluded_channels,
|
||||
blend_if: l.blend_if.clone(),
|
||||
advanced: l.advanced,
|
||||
video: l.video.as_ref().map(|v| video_m(v, sink)),
|
||||
}
|
||||
}
|
||||
@@ -488,6 +489,7 @@ impl Loader<'_> {
|
||||
link_group: m.link_group,
|
||||
excluded_channels: m.excluded_channels,
|
||||
blend_if: m.blend_if.clone(),
|
||||
advanced: m.advanced,
|
||||
video: m.video.as_ref().map(|v| self.video(v)).transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +223,10 @@ pub struct LayerM {
|
||||
/// Blend If ranges (empty = everything blends).
|
||||
#[serde(default, skip_serializing_if = "photocraft_doc::BlendIf::is_default")]
|
||||
pub blend_if: photocraft_doc::BlendIf,
|
||||
/// Advanced Blending switches (knockout, as-group, transparency shapes, masks hide effects);
|
||||
/// omitted at Photoshop's defaults, and missing in older files = the defaults.
|
||||
#[serde(default, skip_serializing_if = "photocraft_doc::AdvancedBlending::is_default")]
|
||||
pub advanced: photocraft_doc::AdvancedBlending,
|
||||
#[serde(default)]
|
||||
pub video: Option<VideoDataM>,
|
||||
}
|
||||
|
||||
@@ -391,6 +391,45 @@ fn blend_if_roundtrips() {
|
||||
assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default()));
|
||||
}
|
||||
|
||||
/// Advanced Blending (knockout, as-group switches, transparency shapes, masks hide effects)
|
||||
/// survives .pcraft at every depth; manifests written before the field existed load with
|
||||
/// Photoshop's defaults, and default layers don't write it.
|
||||
#[test]
|
||||
fn advanced_blending_roundtrips_and_defaults_when_absent() {
|
||||
use photocraft_doc::{AdvancedBlending, Knockout};
|
||||
for depth in SampleType::ALL {
|
||||
let mut doc = rich_doc(ColorMode::Rgb, depth);
|
||||
let a = AdvancedBlending {
|
||||
knockout: Knockout::Deep,
|
||||
blend_interior: true,
|
||||
blend_clipped: false,
|
||||
transparency_shapes: false,
|
||||
layer_mask_hides_effects: true,
|
||||
vector_mask_hides_effects: true,
|
||||
};
|
||||
doc.layers[0].advanced = a;
|
||||
if let Some(l) = doc.layers.get_mut(1) {
|
||||
l.advanced.knockout = Knockout::Shallow;
|
||||
}
|
||||
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
|
||||
let back = load_from_bytes(&bytes).unwrap();
|
||||
assert_eq!(back, doc, "{depth:?}");
|
||||
assert_eq!(back.layers[0].advanced, a);
|
||||
// Only the non-default layers carry the field.
|
||||
let m = read_manifest(&bytes).unwrap();
|
||||
let v = serde_json::to_value(&m.document).unwrap();
|
||||
let with = v["layers"].as_array().unwrap().iter().filter(|l| l.get("advanced").is_some()).count();
|
||||
assert_eq!(with, if doc.layers.len() > 1 { 2 } else { 1 });
|
||||
// An older manifest (no `advanced`) loads with the defaults.
|
||||
let mut old = v.clone();
|
||||
for l in old["layers"].as_array_mut().unwrap() {
|
||||
l.as_object_mut().unwrap().remove("advanced");
|
||||
}
|
||||
let old: photocraft_format::manifest::DocM = serde_json::from_value(old).unwrap();
|
||||
assert!(old.layers.iter().all(|l| l.advanced.is_default()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_layer_frames_survive_roundtrip() {
|
||||
use photocraft_doc::{Timeline, VideoData, VideoSource};
|
||||
|
||||
@@ -489,9 +489,21 @@ impl<'a> Planner<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend
|
||||
/// interior effects as group, transparency shapes off, masks hiding effects) has no GPU pass:
|
||||
/// documents using it are composited on the CPU (`photocraft_compose::advanced`). Layers at
|
||||
/// Photoshop's defaults pass this check for free.
|
||||
fn check_advanced(&self, layer: &Layer, clipped: &[Layer]) -> Result<(), Unsupported> {
|
||||
if photocraft_compose::advanced_active(layer, clipped) {
|
||||
return Err(Unsupported(format!("Advanced Blending on `{}` (composited on the CPU)", layer.name)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// composite_layer, honouring the layer's channel restrictions.
|
||||
fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||||
self.check_blend_if(layer)?;
|
||||
self.check_advanced(layer, clipped)?;
|
||||
match photocraft_compose::channel_weights(layer, self.cx.mode) {
|
||||
Some(w) => {
|
||||
let before = self.retain(backdrop);
|
||||
@@ -665,6 +677,7 @@ impl<'a> Planner<'a> {
|
||||
// Nothing to draw (an empty layer, or a group of them).
|
||||
for c in &visible_clipped {
|
||||
self.check_blend_if(c)?;
|
||||
self.check_advanced(c, &[])?;
|
||||
}
|
||||
return Ok(backdrop);
|
||||
}
|
||||
@@ -836,6 +849,7 @@ impl<'a> Planner<'a> {
|
||||
/// layer's channel restrictions.
|
||||
fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result<Slot, Unsupported> {
|
||||
self.check_blend_if(layer)?;
|
||||
self.check_advanced(layer, &[])?;
|
||||
match photocraft_compose::channel_weights(layer, self.cx.mode) {
|
||||
Some(w) => {
|
||||
let before = self.retain(base);
|
||||
@@ -1739,4 +1753,35 @@ mod tests {
|
||||
d.layers.push(l);
|
||||
assert!(plan(&d).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_blending_falls_back_to_the_cpu() {
|
||||
use photocraft_doc::Knockout;
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("ko", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
d.layers.push(l.clone());
|
||||
assert!(plan(&d).is_ok(), "defaults stay on the GPU");
|
||||
// Switches with nothing to act on (no effects, no clipped layers) stay on the GPU too.
|
||||
d.layers[1].advanced.blend_interior = true;
|
||||
d.layers[1].advanced.blend_clipped = false;
|
||||
assert!(plan(&d).is_ok());
|
||||
d.layers[1].advanced.knockout = Knockout::Deep;
|
||||
assert!(plan(&d).unwrap_err().0.contains("Advanced Blending"));
|
||||
// Inside groups and clipping groups.
|
||||
d.layers[1].advanced.knockout = Knockout::None;
|
||||
let mut c = l.clone();
|
||||
c.clipped = true;
|
||||
d.layers.push(c.clone());
|
||||
assert!(plan(&d).is_err(), "clipped layers blended individually");
|
||||
d.layers[1].advanced.blend_clipped = true;
|
||||
assert!(plan(&d).is_ok());
|
||||
d.layers[2].advanced.knockout = Knockout::Shallow;
|
||||
assert!(plan(&d).is_err(), "clipped knockout");
|
||||
d.layers.truncate(1);
|
||||
let mut inner = l;
|
||||
inner.advanced.knockout = Knockout::Shallow;
|
||||
d.layers.push(Layer::group("g", vec![inner]));
|
||||
assert!(plan(&d).is_err(), "knockout inside a group");
|
||||
}
|
||||
}
|
||||
+86
-1
@@ -2,7 +2,7 @@
|
||||
//! smart objects, locks and label colors.
|
||||
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Fill, GradientStyle, LabelColor, Locks};
|
||||
use photocraft_doc::{AdvancedBlending, BlendIf, BlendRange, Fill, GradientStyle, Knockout, LabelColor, Locks};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
use photocraft_psd::layer::BlendingRanges;
|
||||
@@ -18,6 +18,60 @@ pub fn brst_data(mask: u32) -> Option<Vec<u8>> {
|
||||
(mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect())
|
||||
}
|
||||
|
||||
/// The Advanced Blending blocks, as (key, value for `a`, Photoshop's default value): `knko`
|
||||
/// (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`. Each is stored as one
|
||||
/// byte followed by three bytes of padding (Adobe PSD spec, "Additional Layer Information").
|
||||
pub fn advanced_flags(a: &AdvancedBlending) -> [([u8; 4], u8, u8); 6] {
|
||||
[
|
||||
(*b"knko", a.knockout.to_psd(), 0),
|
||||
(*b"infx", u8::from(a.blend_interior), 0),
|
||||
(*b"clbl", u8::from(a.blend_clipped), 1),
|
||||
(*b"tsly", u8::from(a.transparency_shapes), 1),
|
||||
(*b"lmgm", u8::from(a.layer_mask_hides_effects), 0),
|
||||
(*b"vmgm", u8::from(a.vector_mask_hides_effects), 0),
|
||||
]
|
||||
}
|
||||
|
||||
/// Advanced Blending from a layer record's blocks (`block(key)` gives a block's data). A missing
|
||||
/// or empty block keeps Photoshop's default.
|
||||
pub fn advanced_from_blocks<'a>(block: impl Fn(&[u8; 4]) -> Option<&'a [u8]>) -> AdvancedBlending {
|
||||
let mut a = AdvancedBlending::default();
|
||||
let flag = |k: &[u8; 4]| block(k).and_then(|d| d.first().copied());
|
||||
if let Some(v) = flag(b"knko") {
|
||||
a.knockout = Knockout::from_psd(v);
|
||||
}
|
||||
let set = |k: &[u8; 4], f: &mut bool| {
|
||||
if let Some(v) = flag(k) {
|
||||
*f = v != 0;
|
||||
}
|
||||
};
|
||||
set(b"infx", &mut a.blend_interior);
|
||||
set(b"clbl", &mut a.blend_clipped);
|
||||
set(b"tsly", &mut a.transparency_shapes);
|
||||
set(b"lmgm", &mut a.layer_mask_hides_effects);
|
||||
set(b"vmgm", &mut a.vector_mask_hides_effects);
|
||||
a
|
||||
}
|
||||
|
||||
/// Writes the Advanced Blending blocks into an exported layer's preserved block list: a block
|
||||
/// already there is rewritten in place (keeping Photoshop's block order); a missing one is added
|
||||
/// only when its value differs from the default, so plain layers gain no blocks.
|
||||
pub fn put_advanced(a: &AdvancedBlending, raw: &mut Vec<([u8; 4], Vec<u8>)>) {
|
||||
for (key, v, default) in advanced_flags(a) {
|
||||
match raw.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some(e) => {
|
||||
// Keep any extra bytes a reader wrote; the flag is the first byte.
|
||||
if e.1.len() < 4 {
|
||||
e.1.resize(4, 0);
|
||||
}
|
||||
e.1[0] = v;
|
||||
}
|
||||
None if v != default => raw.push((key, vec![v, 0, 0, 0])),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer-record blending ranges (Adobe PSD spec, "Layer blending ranges data": the composite
|
||||
/// gray source and destination ranges, then a source and destination range per channel, each
|
||||
/// as black low, black high, white low, white high) → Blend If. Full ranges map to the
|
||||
@@ -637,6 +691,37 @@ mod tests {
|
||||
assert_eq!(&r.data[8..], &BlendingRanges::full(2).data[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_blending_blocks_round_trip() {
|
||||
use photocraft_doc::{AdvancedBlending, Knockout};
|
||||
let a = AdvancedBlending {
|
||||
knockout: Knockout::Deep,
|
||||
blend_interior: true,
|
||||
blend_clipped: false,
|
||||
transparency_shapes: false,
|
||||
layer_mask_hides_effects: true,
|
||||
vector_mask_hides_effects: true,
|
||||
};
|
||||
let mut raw = Vec::new();
|
||||
super::put_advanced(&a, &mut raw);
|
||||
assert_eq!(raw.len(), 6);
|
||||
assert!(raw.contains(&(*b"knko", vec![2, 0, 0, 0])));
|
||||
assert!(raw.contains(&(*b"clbl", vec![0, 0, 0, 0])));
|
||||
let get = |k: &[u8; 4]| raw.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.as_slice());
|
||||
assert_eq!(super::advanced_from_blocks(get), a);
|
||||
// Defaults: nothing added to a plain layer; existing blocks rewritten in place, in order.
|
||||
let mut plain = Vec::new();
|
||||
super::put_advanced(&AdvancedBlending::default(), &mut plain);
|
||||
assert!(plain.is_empty());
|
||||
let mut kept = vec![(*b"luni", vec![0; 4]), (*b"knko", vec![1, 0, 0, 0]), (*b"clbl", vec![1])];
|
||||
super::put_advanced(&AdvancedBlending::default(), &mut kept);
|
||||
assert_eq!(kept, vec![(*b"luni", vec![0; 4]), (*b"knko", vec![0, 0, 0, 0]), (*b"clbl", vec![1, 0, 0, 0])]);
|
||||
// Missing and empty blocks read as Photoshop's defaults.
|
||||
assert!(super::advanced_from_blocks(|_| None).is_default());
|
||||
assert!(super::advanced_from_blocks(|_| Some(&[][..])).is_default());
|
||||
assert_eq!(super::advanced_from_blocks(|k| (k == b"knko").then_some(&[1u8][..])).knockout, Knockout::Shallow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brst_round_trip() {
|
||||
assert_eq!(super::parse_brst(&[0, 0, 0, 2]), 0b100);
|
||||
|
||||
@@ -497,6 +497,8 @@ impl Ex {
|
||||
},
|
||||
None => raw.retain(|(k, _)| k != b"brst"),
|
||||
}
|
||||
// Advanced Blending: written from the field (in place when the block was imported).
|
||||
crate::blocks::put_advanced(&l.advanced, &mut raw);
|
||||
if !matches!(l.content, LayerContent::Shape(_)) {
|
||||
self.vector_mask_block(l, &mut raw);
|
||||
}
|
||||
|
||||
@@ -246,6 +246,9 @@ impl Ctx<'_> {
|
||||
if let Some(b) = rec.block(b"brst") {
|
||||
l.excluded_channels = crate::blocks::parse_brst(&b.data);
|
||||
}
|
||||
// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`); the blocks stay in
|
||||
// `psd_blocks`, where export rewrites them from the field in place.
|
||||
l.advanced = crate::blocks::advanced_from_blocks(|k| rec.block(k).map(|b| b.data.as_slice()));
|
||||
// Blend If lives in the layer record's blending ranges.
|
||||
l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges);
|
||||
l.psd_id = rec.layer_id();
|
||||
|
||||
@@ -89,6 +89,9 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
|
||||
neg.fill_opacity = g(128);
|
||||
neg.blend = BlendMode::Multiply;
|
||||
neg.label = LabelColor::Blue;
|
||||
// Advanced Blending switches round-trip as `infx` / `tsly` (and the rest below).
|
||||
neg.advanced.blend_interior = true;
|
||||
neg.advanced.transparency_shapes = false;
|
||||
d.layers.push(neg);
|
||||
|
||||
let mut hidden = raster("Hidden", fmt, Rect::new(3, 3, 9, 9), 3, true);
|
||||
@@ -105,6 +108,8 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
|
||||
if f.masks {
|
||||
let mut m = raster("Masked", fmt, Rect::new(2, 2, 14, 12), 5, true);
|
||||
m.mask = Some(LayerMask { surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
m.advanced.layer_mask_hides_effects = true;
|
||||
m.advanced.vector_mask_hides_effects = true;
|
||||
d.layers.push(m);
|
||||
let mut m2 = raster("Masked2", fmt, Rect::new(-2, 1, 6, 5), 6, true);
|
||||
m2.mask =
|
||||
@@ -120,10 +125,13 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
|
||||
if let LayerContent::Group(gr) = &mut inner.content {
|
||||
gr.expanded = false;
|
||||
}
|
||||
inner.advanced.knockout = photocraft_doc::Knockout::Shallow;
|
||||
let a = raster("Outer child", fmt, Rect::new(6, 2, 12, 8), 8, true);
|
||||
let mut outer = Layer::group("Outer", vec![a, inner]);
|
||||
outer.locks.position = true;
|
||||
outer.label = LabelColor::Red;
|
||||
outer.advanced.knockout = photocraft_doc::Knockout::Deep;
|
||||
outer.advanced.blend_clipped = false;
|
||||
d.layers.push(outer);
|
||||
d.layers.push(Layer::group("Empty group", vec![]));
|
||||
}
|
||||
@@ -235,6 +243,9 @@ pub fn assert_layers_eq(a: &Layer, b: &Layer, path: &str) {
|
||||
assert_eq!(a.fill_opacity, b.fill_opacity, "{ctx}: fill");
|
||||
assert_eq!(a.clipped, b.clipped, "{ctx}: clipped");
|
||||
assert_eq!(a.label, b.label, "{ctx}: label");
|
||||
assert_eq!(a.advanced, b.advanced, "{ctx}: advanced blending");
|
||||
assert_eq!(a.excluded_channels, b.excluded_channels, "{ctx}: channel restrictions");
|
||||
assert_eq!(a.blend_if, b.blend_if, "{ctx}: blend if");
|
||||
assert_eq!(a.effects.psd_raw, b.effects.psd_raw, "{ctx}: effects raw");
|
||||
assert_eq!(a.effects.enabled, b.effects.enabled, "{ctx}: effects enabled");
|
||||
if a.psd_id.is_some() {
|
||||
|
||||
@@ -151,8 +151,8 @@ struct Source {
|
||||
const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 146, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
|
||||
/// 219 vs the merged image + 10 vs the thumbnail.
|
||||
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 229, roundtrip_floor: 307 };
|
||||
/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7).
|
||||
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 307 };
|
||||
|
||||
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
|
||||
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
|
||||
|
||||
@@ -161,6 +161,8 @@ fn raster_layer_blocks_preserved() {
|
||||
let blocks =
|
||||
vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))];
|
||||
d.layers[1].psd_blocks = blocks.clone();
|
||||
// `clbl` is written from the field (in place), so the field agrees with the preserved block.
|
||||
d.layers[1].advanced = photocraft_doc::AdvancedBlending { blend_clipped: false, ..Default::default() };
|
||||
d.layers[1].vector_mask = photocraft_io::vector_map::vector_mask_from_block(&[0; 12], d.size.width, d.size.height);
|
||||
let back = roundtrip(&d);
|
||||
// An odd-length layer block comes back with its pad byte inside the length (as Photoshop
|
||||
@@ -464,6 +466,56 @@ fn channel_restrictions_round_trip_as_brst() {
|
||||
assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none());
|
||||
}
|
||||
|
||||
/// Advanced Blending maps to `knko` / `infx` / `clbl` / `tsly` / `lmgm` / `vmgm` (one byte +
|
||||
/// three of padding), at every depth: non-default values are written, default layers gain no
|
||||
/// blocks, and imported blocks are rewritten in place.
|
||||
#[test]
|
||||
fn advanced_blending_round_trips_as_tagged_blocks() {
|
||||
use photocraft_doc::{AdvancedBlending, Document, Knockout, Layer, Size};
|
||||
for depth in SampleType::ALL {
|
||||
let mut d = Document::new("a", Size::new(8, 8), ColorMode::Rgb, depth);
|
||||
let mut a = Layer::raster("a", d.pixel_format());
|
||||
a.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let adv = AdvancedBlending {
|
||||
knockout: Knockout::Deep,
|
||||
blend_interior: true,
|
||||
blend_clipped: false,
|
||||
transparency_shapes: false,
|
||||
layer_mask_hides_effects: true,
|
||||
vector_mask_hides_effects: true,
|
||||
};
|
||||
a.advanced = adv;
|
||||
let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]);
|
||||
g.advanced.knockout = Knockout::Shallow;
|
||||
g.fill_opacity = 128.0 / 255.0;
|
||||
let b = Layer::raster("b", d.pixel_format());
|
||||
d.layers = vec![a, b, g];
|
||||
let f = document_to_psd(&d);
|
||||
let rec = |name: &str| f.layers().iter().find(|r| r.name() == name).unwrap().clone();
|
||||
let ra = rec("a");
|
||||
for (k, v) in [(b"knko", 2u8), (b"infx", 1), (b"clbl", 0), (b"tsly", 0), (b"lmgm", 1), (b"vmgm", 1)] {
|
||||
assert_eq!(ra.block(k).unwrap().data, vec![v, 0, 0, 0], "{depth:?} {}", String::from_utf8_lossy(k));
|
||||
}
|
||||
for k in [b"knko", b"infx", b"clbl", b"tsly", b"lmgm", b"vmgm"] {
|
||||
assert!(rec("b").block(k).is_none(), "default layer gained {}", String::from_utf8_lossy(k));
|
||||
}
|
||||
assert_eq!(rec("g").block(b"knko").unwrap().data, vec![1, 0, 0, 0]);
|
||||
let back = roundtrip(&d);
|
||||
assert_eq!(back.layers[0].advanced, adv, "{depth:?}");
|
||||
assert!(back.layers[1].advanced.is_default());
|
||||
assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow);
|
||||
assert_eq!(back.layers[2].fill_opacity, 128.0 / 255.0);
|
||||
// Reset to the defaults: the imported blocks stay (in place) with default values.
|
||||
let mut reset = back.clone();
|
||||
reset.layers[0].advanced = AdvancedBlending::default();
|
||||
let f = document_to_psd(&reset);
|
||||
let ra = f.layers().iter().find(|r| r.name() == "a").unwrap();
|
||||
assert_eq!(ra.block(b"knko").unwrap().data, vec![0, 0, 0, 0]);
|
||||
assert_eq!(ra.block(b"clbl").unwrap().data, vec![1, 0, 0, 0]);
|
||||
assert!(roundtrip(&reset).layers[0].advanced.is_default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_round_trips_as_blending_ranges() {
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size};
|
||||
|
||||
@@ -228,6 +228,54 @@ fn psdtags_fixtures_open_with_their_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`), fill opacity (`iOpa`),
|
||||
/// channel restrictions (`brst`) and Blend If (the records' blending ranges) survive a layered
|
||||
/// TIFF with its layer data in either byte order: the encoder's own, and the other one made by
|
||||
/// transcoding the tag (the same length, spliced in place).
|
||||
#[test]
|
||||
fn advanced_blending_survives_layered_tiff_in_both_byte_orders() {
|
||||
use photocraft_doc::{AdvancedBlending, BlendRange, Knockout};
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS);
|
||||
let adv = AdvancedBlending {
|
||||
knockout: Knockout::Deep,
|
||||
blend_interior: true,
|
||||
blend_clipped: false,
|
||||
transparency_shapes: false,
|
||||
layer_mask_hides_effects: true,
|
||||
vector_mask_hides_effects: true,
|
||||
};
|
||||
d.layers[1].advanced = adv;
|
||||
d.layers[1].fill_opacity = 64.0 / 255.0;
|
||||
d.layers[1].excluded_channels = 0b010;
|
||||
d.layers[1].blend_if.set(0, [BlendRange { black: [10, 40], white: [200, 250] }, BlendRange { black: [0, 0], white: [128, 128] }]);
|
||||
d.layers[2].advanced.knockout = Knockout::Shallow;
|
||||
let r = tiff(&d, true);
|
||||
let (_, layers) = tags(&r.bytes);
|
||||
let layers = layers.expect("layer data");
|
||||
let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse");
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
let native = isd.byte_order;
|
||||
let other = if native == ByteOrder::Little { ByteOrder::Big } else { ByteOrder::Little };
|
||||
let (swapped, w) = isd.to_bytes(other).expect("transcode");
|
||||
assert!(w.is_empty(), "{depth:?}: blocks dropped while transcoding: {w:?}");
|
||||
assert_eq!(swapped.len(), layers.len());
|
||||
let at = r.bytes.windows(layers.len()).position(|x| x == &layers[..]).expect("tag data in the file");
|
||||
let mut flipped = r.bytes.clone();
|
||||
flipped[at..at + layers.len()].copy_from_slice(&swapped);
|
||||
for (order, bytes) in [(native, &r.bytes), (other, &flipped)] {
|
||||
let back = import("x.tif", bytes).unwrap_or_else(|e| panic!("{depth:?} {order:?}: {e}")).document;
|
||||
let l = &back.layers[1];
|
||||
assert_eq!(l.advanced, adv, "{depth:?} {order:?}");
|
||||
assert_eq!(l.fill_opacity, 64.0 / 255.0, "{depth:?} {order:?}");
|
||||
assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}");
|
||||
assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}");
|
||||
assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}");
|
||||
assert_docs_eq(&expected(&d), &back);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_layer_data_opens_flattened() {
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
|
||||
@@ -115,8 +115,14 @@ pub enum BlockData<'a> {
|
||||
BlendClippedAsGroup(bool),
|
||||
/// `infx`
|
||||
BlendInteriorElements(bool),
|
||||
/// `knko`
|
||||
/// `knko`: 0 none, 1 shallow, 2 deep.
|
||||
Knockout(u8),
|
||||
/// `tsly`: Transparency Shapes Layer.
|
||||
TransparencyShapes(bool),
|
||||
/// `lmgm`: Layer Mask Hides Effects ("layer mask as global mask").
|
||||
LayerMaskHidesEffects(bool),
|
||||
/// `vmgm`: Vector Mask Hides Effects.
|
||||
VectorMaskHidesEffects(bool),
|
||||
/// `lspf`: bit 0 transparency, bit 1 composite, bit 2 position locked.
|
||||
Protection(u32),
|
||||
/// `lclr`: sheet color index (0 none, 1 red, 2 orange, 3 yellow, 4 green,
|
||||
@@ -170,6 +176,9 @@ impl TaggedBlock {
|
||||
b"clbl" => r.u8().map(|v| BlockData::BlendClippedAsGroup(v != 0)),
|
||||
b"infx" => r.u8().map(|v| BlockData::BlendInteriorElements(v != 0)),
|
||||
b"knko" => r.u8().map(BlockData::Knockout),
|
||||
b"tsly" => r.u8().map(|v| BlockData::TransparencyShapes(v != 0)),
|
||||
b"lmgm" => r.u8().map(|v| BlockData::LayerMaskHidesEffects(v != 0)),
|
||||
b"vmgm" => r.u8().map(|v| BlockData::VectorMaskHidesEffects(v != 0)),
|
||||
b"lspf" => r.u32().map(BlockData::Protection),
|
||||
b"lclr" => r.u16().map(BlockData::SheetColor),
|
||||
b"iOpa" => r.u8().map(BlockData::FillOpacity),
|
||||
@@ -225,6 +234,18 @@ impl TaggedBlock {
|
||||
pub fn knockout(v: u8) -> Self {
|
||||
Self::flag(*b"knko", v)
|
||||
}
|
||||
/// `tsly` block.
|
||||
pub fn transparency_shapes(v: bool) -> Self {
|
||||
Self::flag(*b"tsly", u8::from(v))
|
||||
}
|
||||
/// `lmgm` block.
|
||||
pub fn layer_mask_hides_effects(v: bool) -> Self {
|
||||
Self::flag(*b"lmgm", u8::from(v))
|
||||
}
|
||||
/// `vmgm` block.
|
||||
pub fn vector_mask_hides_effects(v: bool) -> Self {
|
||||
Self::flag(*b"vmgm", u8::from(v))
|
||||
}
|
||||
/// `lspf` block.
|
||||
pub fn protection(flags: u32) -> Self {
|
||||
Self::new(*b"lspf", flags.to_be_bytes().to_vec())
|
||||
@@ -507,6 +528,11 @@ mod tests {
|
||||
assert_eq!(TaggedBlock::blend_clipped_as_group(true).parsed(), Some(Ok(BlockData::BlendClippedAsGroup(true))));
|
||||
assert_eq!(TaggedBlock::blend_interior_elements(false).parsed(), Some(Ok(BlockData::BlendInteriorElements(false))));
|
||||
assert_eq!(TaggedBlock::knockout(2).parsed(), Some(Ok(BlockData::Knockout(2))));
|
||||
assert_eq!(TaggedBlock::transparency_shapes(false).parsed(), Some(Ok(BlockData::TransparencyShapes(false))));
|
||||
assert_eq!(TaggedBlock::layer_mask_hides_effects(true).parsed(), Some(Ok(BlockData::LayerMaskHidesEffects(true))));
|
||||
assert_eq!(TaggedBlock::vector_mask_hides_effects(true).parsed(), Some(Ok(BlockData::VectorMaskHidesEffects(true))));
|
||||
assert_eq!(TaggedBlock::vector_mask_hides_effects(true).data, vec![1, 0, 0, 0]);
|
||||
assert!(matches!(TaggedBlock::new(*b"tsly", vec![]).parsed(), Some(Err(_))), "truncated flag is an error, not a panic");
|
||||
assert_eq!(TaggedBlock::protection(0x8000_0000).parsed(), Some(Ok(BlockData::Protection(0x8000_0000))));
|
||||
assert_eq!(TaggedBlock::sheet_color(3).parsed(), Some(Ok(BlockData::SheetColor(3))));
|
||||
assert_eq!(TaggedBlock::fill_opacity(128).parsed(), Some(Ok(BlockData::FillOpacity(128))));
|
||||
|
||||
+12
-3
@@ -776,8 +776,8 @@ pub mod transcode {
|
||||
self.u32()?;
|
||||
}
|
||||
}
|
||||
// lyvr layer version, sn2P and vowv (shape and vector flags), lmgm.
|
||||
b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" | b"lmgm" => {
|
||||
// lyvr layer version, sn2P and vowv (shape and vector flags).
|
||||
b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" => {
|
||||
self.u32()?;
|
||||
}
|
||||
// Text-engine global data: a textual structure, no byte order.
|
||||
@@ -785,7 +785,9 @@ pub mod transcode {
|
||||
b"lnsr" => {
|
||||
self.key()?;
|
||||
}
|
||||
b"clbl" | b"infx" | b"knko" | b"tsly" | b"iOpa" => {
|
||||
// Advanced Blending flags and fill opacity: one byte, then padding (Adobe spec,
|
||||
// "Additional Layer Information"; `lmgm`/`vmgm` = layer/vector mask hides effects).
|
||||
b"clbl" | b"infx" | b"knko" | b"tsly" | b"lmgm" | b"vmgm" | b"iOpa" => {
|
||||
self.u8()?;
|
||||
}
|
||||
b"lclr" => self.u16s_upto(4)?,
|
||||
@@ -1895,6 +1897,13 @@ mod tests {
|
||||
(*b"lspf", vec![0, 0, 0, 5]),
|
||||
(*b"lnsr", b"layr".to_vec()),
|
||||
(*b"knko", vec![1, 0, 0, 0]),
|
||||
(*b"knko", vec![2, 0, 0, 0]),
|
||||
(*b"infx", vec![1, 0, 0, 0]),
|
||||
(*b"clbl", vec![0, 0, 0, 0]),
|
||||
(*b"tsly", vec![0, 0, 0, 0]),
|
||||
(*b"lmgm", vec![1, 0, 0, 0]),
|
||||
(*b"vmgm", vec![1, 0, 0, 0]),
|
||||
(*b"iOpa", vec![128, 0, 0, 0]),
|
||||
] {
|
||||
let (big, little) = block_rt(key, data.clone());
|
||||
if data.len() >= 2 && key != *b"nvrt" {
|
||||
|
||||
@@ -2145,6 +2145,10 @@ cameraRaw Color Barvy
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Prolnout, pokud:
|
||||
Channels: Kanály:
|
||||
Knockout Vyřazení
|
||||
Apply At Canvas Bounds Použít efekt na hranicích plátna
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2184,6 +2184,11 @@
|
||||
% of all commits % aller Commits
|
||||
Lines +/− Zeilen +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last}
|
||||
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Farbbereich:
|
||||
Channels: Kanäle:
|
||||
Knockout Aussparung
|
||||
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2216,6 +2216,10 @@ cameraRaw Amount Cantidad
|
||||
% of all commits % de todos los commits
|
||||
Lines +/− Líneas +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Combinar si:
|
||||
Channels: Canales:
|
||||
Knockout Cobertura
|
||||
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2576,6 +2576,10 @@ cameraRaw Blending Fusion
|
||||
% of all commits % de tous les commits
|
||||
Lines +/− Lignes +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Fusion si :
|
||||
Channels: Couches :
|
||||
Knockout Masquage
|
||||
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2563,6 +2563,10 @@ cameraRaw Blending Pembauran
|
||||
% of all commits % dari semua commit
|
||||
Lines +/− Baris +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Blend If:
|
||||
Channels: Channel:
|
||||
Knockout Knockout
|
||||
Apply At Canvas Bounds Terapkan efek di batas kanvas
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2217,6 +2217,10 @@ cameraRaw Amount Quantità
|
||||
% of all commits % di tutti i commit
|
||||
Lines +/− Righe +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{binAdded} / −{binDeleted} risorse binarie, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Fondi se:
|
||||
Channels: Canali:
|
||||
Knockout Foratura
|
||||
|
||||
Convert to Layers Converti in livelli
|
||||
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
|
||||
|
||||
|
@@ -2128,6 +2128,10 @@ cameraRaw Color カラー
|
||||
% of all commits 全コミットに占める割合 (%)
|
||||
Lines +/− 行数 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: 条件付き合成:
|
||||
Channels: チャンネル:
|
||||
Knockout ノックアウト
|
||||
Apply At Canvas Bounds カンバスの境界に効果を適用
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2729,6 +2729,10 @@ cameraRaw Color 색상
|
||||
% of all commits 전체 커밋 대비 %
|
||||
Lines +/− 줄 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: 혼합 조건:
|
||||
Channels: 채널:
|
||||
Knockout 녹아웃
|
||||
Apply At Canvas Bounds 캔버스 경계에 효과 적용
|
||||
Good 좋음
|
||||
Best 최적
|
||||
|
||||
|
@@ -3184,6 +3184,9 @@ cameraRaw Roughness Chropowatość
|
||||
More documents Więcej dokumentów
|
||||
Developing {name} Wywoływanie {name}
|
||||
Camera Raw ({name}) Camera Raw ({name})
|
||||
Blend If: Mieszaj, jeśli:
|
||||
Channels: Kanały:
|
||||
Knockout Wycięcie
|
||||
New Brush Preset… Nowe ustawienie wstępne pędzla…
|
||||
Brush Preset Options Opcje ustawień wstępnych pędzla
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2583,6 +2583,11 @@ cameraRaw Whites Brancos
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last}
|
||||
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Mesclar se:
|
||||
Channels: Canais:
|
||||
Knockout Vazado
|
||||
Apply At Canvas Bounds Aplicar efeito nos limites da tela
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2422,6 +2422,10 @@ cameraRaw Roughness Неровность
|
||||
% of all commits % от всех коммитов
|
||||
Lines +/− Строки +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: Изображение если:
|
||||
Channels: Каналы:
|
||||
Knockout Просвечивание
|
||||
Apply At Canvas Bounds Применить эффект на границах холста
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2153,6 +2153,10 @@ cameraRaw Blending 混合
|
||||
% of all commits 占全部提交的 %
|
||||
Lines +/− 行数 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: 混合颜色带:
|
||||
Channels: 通道:
|
||||
Knockout 挖空
|
||||
Apply At Canvas Bounds 应用画布边界的效果
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
@@ -2122,6 +2122,10 @@ cameraRaw Blending 混合
|
||||
% of all commits 佔全部提交的 %
|
||||
Lines +/− 行數 +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last}
|
||||
# Layer Style > Blending Options (Advanced Blending)
|
||||
Blend If: 混合範圍:
|
||||
Channels: 色版:
|
||||
Knockout 去底色
|
||||
Apply At Canvas Bounds 套用效果至版面邊界
|
||||
|
||||
# Fill dialog, layer style contours and Image Size
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -9,7 +9,9 @@
|
||||
//! snapshot with `params` (engine-side overlay), so everything the dialog
|
||||
//! doesn't model — gradient strokes, imported contours, PSD data — survives.
|
||||
//! An entry without `fx` is a new instance built from `params`.
|
||||
//! - `p:blendingOptions`: the layer's blend mode, opacity and fill opacity.
|
||||
//! - `p:blendingOptions`: the parameters of `layer.layerStyle.blendingOptions` (blend mode,
|
||||
//! opacity, fill opacity, channels, knockout, the Advanced Blending switches, Blend If) plus
|
||||
//! the page-only `blendIfChannel`; `channelNames`: the document's colour channels.
|
||||
|
||||
use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2};
|
||||
use photocraft_doc::effects::{FxPaint, StrokePosition};
|
||||
@@ -445,10 +447,7 @@ pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map<St
|
||||
f.insert("layer".into(), json!(layer.id.0));
|
||||
f.insert("globalLight".into(), json!(light));
|
||||
f.insert("preview".into(), json!(true));
|
||||
f.insert(
|
||||
format!("p:{BLENDING}"),
|
||||
json!({"blend": layer.blend.label(), "opacity": (layer.opacity * 100.0).round(), "fillOpacity": (layer.fill_opacity * 100.0).round()}),
|
||||
);
|
||||
set_blending_fields(&mut f, layer, photocraft_color::ColorMode::Rgb);
|
||||
let mut effects = Vec::new();
|
||||
for (i, e) in layer.effects.items.iter().enumerate() {
|
||||
effects.push(json!({
|
||||
@@ -491,7 +490,10 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option<u64> {
|
||||
let st = app.session.active()?;
|
||||
let layer = st.doc.layer(st.active_layer?)?.clone();
|
||||
let light = st.doc.global_light.angle;
|
||||
let mode = st.doc.mode;
|
||||
let mut f = initial_fields(&layer, select, light);
|
||||
// Channels and Blend If list the document's own colour channels.
|
||||
set_blending_fields(&mut f, &layer, mode);
|
||||
f.insert("patternList".into(), pattern_list(app));
|
||||
Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f))
|
||||
}
|
||||
@@ -548,10 +550,11 @@ fn apply(f: &Map<String, Value>, mut run: impl FnMut(&str, Value) -> Result<Valu
|
||||
let layer = f.get("layer").cloned().unwrap_or(Value::Null);
|
||||
let initial_light_angle = f.get("globalLight").and_then(Value::as_f64);
|
||||
if let Some(bo) = f.get(&format!("p:{BLENDING}")).and_then(Value::as_object) {
|
||||
let mut p = Value::Object(bo.clone());
|
||||
if let Some(params) = p.as_object_mut() {
|
||||
params.insert("layer".into(), layer.clone());
|
||||
}
|
||||
let mut bo = bo.clone();
|
||||
// Which Blend If channel the page shows is dialog state, not a parameter.
|
||||
bo.remove("blendIfChannel");
|
||||
bo.insert("layer".into(), layer.clone());
|
||||
let p = Value::Object(bo);
|
||||
run("layer.layerStyle.blendingOptions", p)?;
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
@@ -849,6 +852,15 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
ui.colored_label(t.danger, error);
|
||||
return;
|
||||
}
|
||||
if selected == BLENDING {
|
||||
// General Blending, Advanced Blending and Blend If, laid out like Photoshop's page.
|
||||
let names = channel_names_of(f);
|
||||
let key = format!("p:{BLENDING}");
|
||||
let Some(mut p) = f.get(&key).filter(|v| v.is_object()).cloned() else { return };
|
||||
blending_page(ui, &mut p, &names);
|
||||
f.insert(key, p);
|
||||
return;
|
||||
}
|
||||
// The page shows the factory defaults under the instance's values,
|
||||
// but only real edits reach the stored params.
|
||||
let disp = if selected == BLENDING {
|
||||
@@ -985,6 +997,274 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour channel names of a document mode, as Blending Options lists them (Channels, Blend If).
|
||||
pub fn channel_names(mode: photocraft_color::ColorMode) -> Vec<&'static str> {
|
||||
use photocraft_color::ColorMode as M;
|
||||
match mode {
|
||||
M::Rgb | M::Indexed | M::Multichannel => vec!["R", "G", "B"],
|
||||
M::Cmyk => vec!["C", "M", "Y", "K"],
|
||||
M::Lab => vec!["L", "a", "b"],
|
||||
_ => vec!["Gray"],
|
||||
}
|
||||
}
|
||||
|
||||
const KNOCKOUT: &[(&str, &str)] = &[("none", "None"), ("shallow", "Shallow"), ("deep", "Deep")];
|
||||
|
||||
/// (parameter key, label) of the Advanced Blending check boxes, in Photoshop's order.
|
||||
const ADVANCED_CHECKS: &[(&str, &str)] = &[
|
||||
("blendInteriorEffectsAsGroup", "Blend Interior Effects as Group"),
|
||||
("blendClippedLayersAsGroup", "Blend Clipped Layers as Group"),
|
||||
("transparencyShapesLayer", "Transparency Shapes Layer"),
|
||||
("layerMaskHidesEffects", "Layer Mask Hides Effects"),
|
||||
("vectorMaskHidesEffects", "Vector Mask Hides Effects"),
|
||||
];
|
||||
|
||||
/// The Blending Options page's fields for `layer` in a `mode` document (the parameters of
|
||||
/// `layer.layerStyle.blendingOptions`, plus the page's Blend If channel), and the channel names.
|
||||
pub fn set_blending_fields(f: &mut Map<String, Value>, layer: &Layer, mode: photocraft_color::ColorMode) {
|
||||
let names = channel_names(mode);
|
||||
let n = names.len();
|
||||
let a = &layer.advanced;
|
||||
// Blend If entries in the command's form: index 0 is Gray for colour documents; a one-channel
|
||||
// document's Gray is its channel's own entry (index 1).
|
||||
let first = if n == 1 { 1 } else { 0 };
|
||||
let blend_if: Vec<Value> = (first..=n)
|
||||
.map(|i| {
|
||||
let [this, under] = layer.blend_if.get(i);
|
||||
json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()})
|
||||
})
|
||||
.collect();
|
||||
f.insert(
|
||||
format!("p:{BLENDING}"),
|
||||
json!({
|
||||
"blend": layer.blend.label(),
|
||||
"opacity": (layer.opacity * 100.0).round(),
|
||||
"fillOpacity": (layer.fill_opacity * 100.0).round(),
|
||||
"channels": (0..n).map(|i| layer.excluded_channels & (1 << i) == 0).collect::<Vec<_>>(),
|
||||
"knockout": a.knockout.name(),
|
||||
"blendInteriorEffectsAsGroup": a.blend_interior,
|
||||
"blendClippedLayersAsGroup": a.blend_clipped,
|
||||
"transparencyShapesLayer": a.transparency_shapes,
|
||||
"layerMaskHidesEffects": a.layer_mask_hides_effects,
|
||||
"vectorMaskHidesEffects": a.vector_mask_hides_effects,
|
||||
"blendIfChannel": 0,
|
||||
"blendIf": blend_if,
|
||||
}),
|
||||
);
|
||||
f.insert("channelNames".into(), json!(names));
|
||||
}
|
||||
|
||||
fn channel_names_of(f: &Map<String, Value>) -> Vec<String> {
|
||||
f.get("channelNames")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| ["R", "G", "B"].iter().map(|s| (*s).to_string()).collect())
|
||||
}
|
||||
|
||||
/// The Blending Options page: General Blending, Advanced Blending and Blend If, as in Photoshop.
|
||||
fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let num = |p: &Value, k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32;
|
||||
widgets::section_label(ui, "General Blending");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Blend Mode")).color(t.text_dim));
|
||||
let mut cur = p.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string();
|
||||
let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect();
|
||||
if widgets::dropdown(ui, "fx-blend-blendingOptions", &mut cur, &opts, 150.0) {
|
||||
p["blend"] = json!(cur);
|
||||
}
|
||||
});
|
||||
let mut v = num(p, "opacity", 100.0);
|
||||
if widgets::slider_row(ui, "Opacity", &mut v, 0.0..=100.0, "%", None).changed() {
|
||||
p["opacity"] = json!(v.round());
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
widgets::section_label(ui, "Advanced Blending");
|
||||
let mut v = num(p, "fillOpacity", 100.0);
|
||||
if widgets::slider_row(ui, "Fill Opacity", &mut v, 0.0..=100.0, "%", None).changed() {
|
||||
p["fillOpacity"] = json!(v.round());
|
||||
}
|
||||
// Channels: one check box per colour channel (checked = blends).
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Channels:")).color(t.text_dim));
|
||||
let mut ch: Vec<bool> =
|
||||
p.get("channels").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_bool().unwrap_or(true)).collect()).unwrap_or_default();
|
||||
ch.resize(names.len(), true);
|
||||
let mut changed = false;
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
if let Some(c) = ch.get_mut(i) {
|
||||
changed |= widgets::checkbox(ui, c, name).changed();
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
p["channels"] = json!(ch);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Knockout")).color(t.text_dim));
|
||||
let mut cur = p.get("knockout").and_then(Value::as_str).unwrap_or("none").to_string();
|
||||
let opts: Vec<(String, &str)> = KNOCKOUT.iter().map(|(v, l)| (v.to_string(), *l)).collect();
|
||||
if widgets::dropdown(ui, "fx-blendingOptions-knockout", &mut cur, &opts, 150.0) {
|
||||
p["knockout"] = json!(cur);
|
||||
}
|
||||
});
|
||||
for &(key, label) in ADVANCED_CHECKS {
|
||||
let default = matches!(key, "blendClippedLayersAsGroup" | "transparencyShapesLayer");
|
||||
let mut b = p.get(key).and_then(Value::as_bool).unwrap_or(default);
|
||||
if widgets::checkbox(ui, &mut b, label).changed() {
|
||||
p[key] = json!(b);
|
||||
}
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
// Blend If: the channel, then This Layer / Underlying Layer split sliders (Alt-drag splits).
|
||||
let entries = p.get("blendIf").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
if entries == 0 {
|
||||
return;
|
||||
}
|
||||
let sel = p.get("blendIfChannel").and_then(Value::as_u64).map_or(0, |v| v as usize).min(entries - 1);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Blend If:")).color(t.text_dim));
|
||||
// A colour document lists Gray first; a one-channel document only its channel (Gray).
|
||||
let label = |i: usize| -> String {
|
||||
if entries == names.len() {
|
||||
names.get(i).cloned().unwrap_or_default()
|
||||
} else if i == 0 {
|
||||
"Gray".to_string()
|
||||
} else {
|
||||
names.get(i - 1).cloned().unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let labels: Vec<String> = (0..entries).map(label).collect();
|
||||
let opts: Vec<(usize, &str)> = labels.iter().enumerate().map(|(i, l)| (i, l.as_str())).collect();
|
||||
let mut cur = sel;
|
||||
if widgets::dropdown(ui, "fx-blendingOptions-blendIf", &mut cur, &opts, 110.0) {
|
||||
p["blendIfChannel"] = json!(cur);
|
||||
}
|
||||
});
|
||||
for (key, label) in [("thisLayer", "This Layer:"), ("underlying", "Underlying Layer:")] {
|
||||
let mut q = blend_quad(p.get("blendIf").and_then(|a| a.get(sel)).and_then(|e| e.get(key)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!(label)).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let side = |lo: u8, hi: u8| if lo == hi { lo.to_string() } else { format!("{lo} / {hi}") };
|
||||
ui.label(RichText::new(format!("{} {}", side(q[0], q[1]), side(q[2], q[3]))).color(t.text));
|
||||
});
|
||||
});
|
||||
if blend_if_bar(ui, ui.id().with(("blendIf", key)), &mut q)
|
||||
&& let Some(e) = p.get_mut("blendIf").and_then(|a| a.get_mut(sel))
|
||||
{
|
||||
e[key] = json!(q);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Blend If range from the dialog's JSON (`[blackLo, blackHi, whiteLo, whiteHi]`), full when absent.
|
||||
fn blend_quad(v: Option<&Value>) -> [u8; 4] {
|
||||
let mut q = [0u8, 0, 255, 255];
|
||||
if let Some(a) = v.and_then(Value::as_array) {
|
||||
for (dst, x) in q.iter_mut().zip(a) {
|
||||
*dst = x.as_f64().map_or(*dst, |f| f.clamp(0.0, 255.0).round() as u8);
|
||||
}
|
||||
}
|
||||
// The sliders never cross.
|
||||
for i in 1..4 {
|
||||
q[i] = q[i].max(q[i - 1]);
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// A Blend If slider: a black-to-white ramp with a black and a white handle, each split in two
|
||||
/// halves by Alt-dragging (the layer fades between them). Returns whether `q` changed.
|
||||
fn blend_if_bar(ui: &mut egui::Ui, id: egui::Id, q: &mut [u8; 4]) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width().clamp(120.0, 320.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click_and_drag());
|
||||
let ramp = egui::Rect::from_min_size(rect.min + vec2(6.0, 0.0), vec2(w - 12.0, 12.0));
|
||||
let x_of = |v: u8| ramp.left() + ramp.width() * f32::from(v) / 255.0;
|
||||
let v_of = |x: f32| (((x - ramp.left()) / ramp.width().max(1.0)) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
// The ramp, drawn as vertical strips.
|
||||
let strips = 32;
|
||||
for s in 0..strips {
|
||||
let x0 = ramp.left() + ramp.width() * s as f32 / strips as f32;
|
||||
let x1 = ramp.left() + ramp.width() * (s + 1) as f32 / strips as f32;
|
||||
let g = (255.0 * (s as f32 + 0.5) / strips as f32) as u8;
|
||||
ui.painter().rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, Color32::from_gray(g));
|
||||
}
|
||||
ui.painter().rect_stroke(ramp, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
// Handles: triangles under the ramp; a split handle shows two half triangles.
|
||||
let mut changed = false;
|
||||
let drag_key = id.with("handle");
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let near = (0..4).min_by(|a, b| (x_of(q[*a]) - pos.x).abs().total_cmp(&(x_of(q[*b]) - pos.x).abs())).unwrap_or(0);
|
||||
// Between two coinciding halves, pick by the side of the pointer.
|
||||
let near = match near {
|
||||
0 | 1 if q[0] == q[1] => usize::from(pos.x > x_of(q[0])),
|
||||
2 | 3 if q[2] == q[3] => 2 + usize::from(pos.x > x_of(q[2])),
|
||||
n => n,
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(drag_key, near));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(h), Some(pos)) = (ui.data(|d| d.get_temp::<usize>(drag_key)), resp.interact_pointer_pos())
|
||||
&& let Some(cur) = q.get(h).copied()
|
||||
{
|
||||
let target = v_of(pos.x);
|
||||
let split = ui.input(|i| i.modifiers.alt);
|
||||
let mut n = *q;
|
||||
let pair = if h < 2 { 0 } else { 2 };
|
||||
if split {
|
||||
n[h] = target;
|
||||
} else {
|
||||
// Unsplit: the pair moves together, keeping its spread.
|
||||
let delta = i16::from(target) - i16::from(cur);
|
||||
for v in &mut n[pair..pair + 2] {
|
||||
*v = (i16::from(*v) + delta).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
// Keep the order black low <= black high <= white low <= white high, pushing the others.
|
||||
for i in 0..4 {
|
||||
if i < h {
|
||||
n[i] = n[i].min(n[h]);
|
||||
} else if i > h {
|
||||
n[i] = n[i].max(n[h]);
|
||||
}
|
||||
}
|
||||
for i in 1..4 {
|
||||
n[i] = n[i].max(n[i - 1]);
|
||||
}
|
||||
if n != *q {
|
||||
*q = n;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let base = ramp.bottom() + 1.0;
|
||||
let tri = |x: f32, half: i8, fill: Color32| {
|
||||
let (l, r) = match half {
|
||||
-1 => (x - 5.0, x),
|
||||
1 => (x, x + 5.0),
|
||||
_ => (x - 5.0, x + 5.0),
|
||||
};
|
||||
let pts = vec![egui::pos2(x, base), egui::pos2(r, base + 10.0), egui::pos2(l, base + 10.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, fill, Stroke::new(1.0, t.text_dim)));
|
||||
};
|
||||
let dark = Color32::from_gray(20);
|
||||
let light = Color32::from_gray(240);
|
||||
for (pair, fill) in [(0usize, dark), (2, light)] {
|
||||
let (a, b) = (q[pair], q[pair + 1]);
|
||||
if a == b {
|
||||
tri(x_of(a), 0, fill);
|
||||
} else {
|
||||
tri(x_of(a), -1, fill);
|
||||
tri(x_of(b), 1, fill);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Light direction uses document angles: counter-clockwise from three o'clock.
|
||||
fn angle_row(ui: &mut egui::Ui, label: &str, angle: &mut f32) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -1267,6 +1547,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_page_renders_for_every_mode_and_odd_fields() {
|
||||
let ctx = egui::Context::default();
|
||||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let mut layer = photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Adjustment(photocraft_doc::Adjustment::Invert));
|
||||
layer.advanced.knockout = photocraft_doc::Knockout::Deep;
|
||||
for mode in
|
||||
[photocraft_color::ColorMode::Rgb, photocraft_color::ColorMode::Cmyk, photocraft_color::ColorMode::Lab, photocraft_color::ColorMode::Grayscale]
|
||||
{
|
||||
let mut f = initial_fields(&layer, Some(BLENDING), 120.0);
|
||||
set_blending_fields(&mut f, &layer, mode);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f));
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(f["p:blendingOptions"]["knockout"], "deep", "{mode:?}");
|
||||
// Hostile dialog state (only reachable through `ui.dialog.set`) still renders.
|
||||
let bo = f.get_mut("p:blendingOptions").unwrap();
|
||||
bo["blendIfChannel"] = json!(99);
|
||||
bo["channels"] = json!("x");
|
||||
bo["blendIf"] = json!([{"thisLayer": [300, -5, "a"]}]);
|
||||
f.insert("channelNames".into(), json!([]));
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_rejects_invalid_params_and_applies_valid_effects() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
@@ -1284,6 +1590,55 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().doc.layer(layer.id).unwrap().effects.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_page_round_trips_advanced_blending_and_blend_if() {
|
||||
use photocraft_doc::{BlendRange, Knockout};
|
||||
for mode in ["rgb", "cmyk", "grayscale"] {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16, "mode": mode})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
let mut l = st.doc.layer(id).unwrap().clone();
|
||||
l.advanced.knockout = Knockout::Shallow;
|
||||
l.advanced.blend_clipped = false;
|
||||
l.excluded_channels = 0b1;
|
||||
let gray = if st.doc.mode.color_channels() == 1 { 1 } else { 0 };
|
||||
l.blend_if.set(gray, [BlendRange { black: [10, 30], white: [255, 255] }, BlendRange::FULL]);
|
||||
let mut f = initial_fields(&l, None, 120.0);
|
||||
set_blending_fields(&mut f, &l, st.doc.mode);
|
||||
let names = channel_names(st.doc.mode);
|
||||
assert_eq!(f["channelNames"], json!(names), "{mode}");
|
||||
let p = &f[&format!("p:{BLENDING}")];
|
||||
assert_eq!(p["knockout"], "shallow");
|
||||
assert_eq!(p["blendClippedLayersAsGroup"], false);
|
||||
assert_eq!(p["transparencyShapesLayer"], true);
|
||||
assert_eq!(p["channels"].as_array().unwrap().len(), names.len());
|
||||
assert_eq!(p["channels"][0], false);
|
||||
assert_eq!(p["blendIf"][0]["thisLayer"], json!([10, 30, 255, 255]), "{mode}");
|
||||
// Applying the page sets every switch through the engine command.
|
||||
let mut f2 = f.clone();
|
||||
let bo = f2.get_mut(&format!("p:{BLENDING}")).unwrap();
|
||||
bo["knockout"] = json!("deep");
|
||||
bo["vectorMaskHidesEffects"] = json!(true);
|
||||
bo["blendIfChannel"] = json!(names.len().min(1));
|
||||
let shown = preview_document(&st.doc, &s.patterns, &f2).unwrap();
|
||||
let got = shown.layer(id).unwrap();
|
||||
assert_eq!(got.advanced.knockout, Knockout::Deep, "{mode}");
|
||||
assert!(got.advanced.vector_mask_hides_effects);
|
||||
assert!(!got.advanced.blend_clipped);
|
||||
assert_eq!(got.excluded_channels, 0b1);
|
||||
assert_eq!(got.blend_if.get(gray)[0], BlendRange { black: [10, 30], white: [255, 255] }, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_if_values_stay_ordered() {
|
||||
assert_eq!(blend_quad(Some(&json!([200, 10, 5, 300]))), [200, 200, 200, 255]);
|
||||
assert_eq!(blend_quad(None), [0, 0, 255, 255]);
|
||||
assert_eq!(blend_quad(Some(&json!(["x", null]))), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_fields_round_trip_through_the_engine() {
|
||||
let mut s = session();
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
|
||||
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|
||||
|---|---|---|---|
|
||||
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
|
||||
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
|
||||
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
|
||||
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
|
||||
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
|
||||
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
|
||||
|
||||
+5
-4
@@ -7,8 +7,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 3 | 11 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 8 | 14 | 8 | |
|
||||
| [File compatibility](#file-compatibility) | 3 | 12 | 12 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 9 | 14 | 8 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
|
||||
@@ -84,7 +84,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|
||||
|---|---|---:|---:|---:|
|
||||
| io corpus | `corpus/psd` | 170 | 146 | 169 |
|
||||
| psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 |
|
||||
| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 307 |
|
||||
| photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 |
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
@@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
|
||||
| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] |
|
||||
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only |
|
||||
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No knockout / blend_interior / transparency_shapes in doc, compose or io |
|
||||
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ |
|
||||
| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only |
|
||||
| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults |
|
||||
| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) |
|
||||
@@ -151,6 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog |
|
||||
| TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog |
|
||||
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo |
|
||||
| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error |
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
|
||||
@@ -113,9 +113,9 @@ note = "doc effects.rs:57 Contour model only"
|
||||
[[item]]
|
||||
id = "FILE-215-16"
|
||||
target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "No knockout / blend_interior / transparency_shapes in doc, compose or io"
|
||||
note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-17"
|
||||
|
||||
Reference in new issue
Block a user