Compare commits

...
19 changed files with 949 additions and 107 deletions

No files matched your search

+125 -15
View File
@@ -1001,6 +1001,17 @@ pub struct FxMaps {
dout: Option<Vec<f32>>,
/// Shape layers: distance outside the vector outline (from local coverage).
vdout: Option<Vec<f32>>,
/// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).
outline: bool,
/// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].
frames: Vec<(u32, Rect)>,
}
impl FxMaps {
/// The frame of a gradient stroke `st` of this layer (see [`stroke_frame`]), if built.
pub fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
self.frames.iter().find(|f| f.0 == stroke_widths(st).1.to_bits()).map(|f| f.1)
}
}
impl FxMaps {
@@ -1090,8 +1101,21 @@ pub(crate) fn build_maps_prepared(
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) };
let vdout = (has_stroke && vector_shape).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout }
// Without an outline (unfilled shapes), estimate the vector outline from local coverage.
let outline = crate::effect_outline(layer).is_some();
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
let mut frames: Vec<(u32, Rect)> = Vec::new();
for e in &items {
if let Effect::Stroke(st) = e
&& matches!(st.paint, FxPaint::Gradient(_))
{
let out_w = stroke_widths(st).1;
if !frames.iter().any(|f| f.0 == out_w.to_bits()) {
frames.push((out_w.to_bits(), stroke_frame(&shape, out_w, rect)));
}
}
}
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames }
}
/// Far outside any shape (distance fill for cropped distance fields).
@@ -1101,7 +1125,7 @@ const FAR: f32 = 1.0e9;
/// masked, clipped layers applied) plus its effects into `backdrop`.
pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) {
let prepared = PreparedPatterns::new(patterns, PREPARED_PATTERN_BYTES);
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared);
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None);
}
pub(crate) fn composite_with_effects_prepared(
@@ -1111,10 +1135,21 @@ pub(crate) fn composite_with_effects_prepared(
maps: &FxMaps,
layer_bounds: Rect,
patterns: &PreparedPatterns<'_>,
vstroke: Option<VectorStroke<'_>>,
) {
let big = content.rect;
let (w, h) = (big.width() as usize, big.height() as usize);
let shape = Map { w, h, v: content.px.iter().map(|p| p[3]).collect() };
// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
// 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 {
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 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).
@@ -1163,7 +1198,18 @@ pub(crate) fn composite_with_effects_prepared(
// keeps its alpha, as in Photoshop.
let fill = layer.fill_opacity;
let inside = |a: f32| a > INSIDE_EPS;
let mut lay = Buffer { rect: big, px: content.px.iter().map(|p| [p[0], p[1], p[2], if inside(p[3]) { fill } else { 0.0 }]).collect() };
// 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.
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
if !inside(a) {
0.0
} else if relative {
fill * (kmask(i) * p[3] / a).min(1.0)
} else {
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() };
let rel = |m: Map| -> Map {
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
Map { w: m.w, h: m.h, v }
@@ -1202,6 +1248,15 @@ pub(crate) fn composite_with_effects_prepared(
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
// interior effects, below the stroke effect (psd-tools stroke-composite).
if let Some(vs) = &vstroke {
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
if s[3] > 0.0 && inside(*a) {
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0);
}
}
}
// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
// are slid beneath it, so the first listed (top) instance is processed first.
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
@@ -1209,11 +1264,9 @@ pub(crate) fn composite_with_effects_prepared(
(Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)),
_ => (Vec::new(), Vec::new()),
};
let widths = |st: &photocraft_doc::StrokeFx| match st.position {
StrokePosition::Outside => (0.0, st.size),
StrokePosition::Inside => (st.size, 0.0),
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
};
let widths = stroke_widths;
// A gradient stroke aligned with the layer spans the stroke's own extent.
let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb);
for st in strokes.iter().rev().copied() {
let (in_w, _) = widths(st);
if in_w <= 0.0 {
@@ -1223,7 +1276,7 @@ pub(crate) fn composite_with_effects_prepared(
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) {
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
}
paint_fx(&mut lay, &m, &st.paint, sb, anchor, big, st.common.blend, st.common.opacity, patterns);
paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns);
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
@@ -1253,19 +1306,27 @@ pub(crate) fn composite_with_effects_prepared(
if out_w <= 0.0 {
continue;
}
// Shape layers: Photoshop strokes the vector outline, estimated from
// local coverage; the stroke never shows through the shape's pixels.
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
// filled shape's outline it covers the part of each edge pixel outside the path.
let d = vdout.as_ref().unwrap_or(&dout);
let mut share = vec![0.0f32; w * h];
for (i, sh) in share.iter_mut().enumerate() {
let k = if inside(shape.v[i]) { if vector_shape { 0.0 } else { 1.0 } } else { (out_w + 0.5 - d[i]).clamp(0.0, 1.0) };
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
let k = if maps.outline {
band * outline_share(shape.v[i], lay.px[i][3])
} else if inside(shape.v[i]) {
if vector_shape { 0.0 } else { 1.0 }
} else {
band
};
*sh = k * st.common.opacity * (1.0 - cover[i]);
cover[i] += *sh;
}
// The stroke blended over the backdrop at full coverage.
let mut blended = base.clone();
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
paint_fx(&mut blended, &ones, &st.paint, sb, anchor, big, st.common.blend, 1.0, patterns);
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns);
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
if *s > 0.0 {
for c in 0..3 {
@@ -1327,6 +1388,55 @@ pub(crate) fn composite_with_effects_prepared(
}
}
/// Coverage of an outside stroke beneath a filled shape's edge pixel, for a pixel the outline
/// covers `cov` of and the layer (with its interior effects) ends at alpha `l`. Photoshop treats
/// the stroke and the shape as disjoint areas: the stroke fills the part outside the path and
/// the layer adds its own alpha (psd-tools stroke-effects: a 58 % covered edge pixel of an
/// opaque fill ends opaque, one of a faded fill at 42 % + its alpha). Composited beneath the
/// layer, `share + l (1 - share) = (1 - cov) + l`.
pub fn outline_share(cov: f32, l: f32) -> f32 {
let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0);
let rest = 1.0 - l.clamp(0.0, 1.0);
if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) }
}
/// (inside width, outside width) of a stroke.
pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) {
match st.position {
StrokePosition::Outside => (0.0, st.size),
StrokePosition::Inside => (st.size, 0.0),
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
}
}
/// The frame a gradient stroke aligned with the layer is laid out in: the pixel bounds of the
/// effect shape (over `rect`) grown by the stroke's outside width less one pixel. Photoshop
/// spans the gradient over the stroke's extent, not the layer's: a 4 px outside stroke around a
/// 22 px square runs its 90° gradient over 28 px (psd-tools stroke-effects), a 3 px one around
/// 36 px of type over 40 px (psd-tools effect-stroke-gradient).
fn stroke_frame(shape: &Map, out_w: f32, rect: Rect) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
for y in 0..shape.h {
for x in 0..shape.w {
if shape.v[y * shape.w + x] > INSIDE_EPS {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
}
}
if x0 == usize::MAX {
return rect;
}
let grow = if out_w.is_finite() { (out_w.min(MAX_REACH).ceil() as i32 - 1).max(0) } else { 0 };
Rect::new(rect.x0 + x0 as i32, rect.y0 + y0 as i32, rect.x0 + x1 as i32, rect.y0 + y1 as i32).inflate(grow)
}
/// A stroked shape's vector stroke, composited apart from its fill (which the compositor then
/// passes unmasked): the stroke's pixels over the render rect and the layer's mask values.
pub(crate) struct VectorStroke<'a> {
pub stroke: &'a Buffer,
pub mask: Option<&'a [f32]>,
}
/// Premultiplied linear interpolation between two straight-alpha pixels.
fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
let alpha = a[3] + (b[3] - a[3]) * k;
+116 -29
View File
@@ -511,10 +511,67 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
}
}),
LayerContent::Fill(_) => canvas,
_ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds),
_ => {
let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds);
// Effects follow a filled shape's outline, also where its fill is transparent.
match effect_outline(layer).and_then(|_| paint_bounds(layer)).filter(|_| effects::has_effects(layer)) {
Some(p) if !b.is_empty() => b.union(&p),
Some(p) => p,
None => b,
}
}
}
}
/// The path a shape layer's effects are shaped by: its fill path. Photoshop builds a filled
/// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the
/// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other
/// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches
/// past the path (their pixels give the outline: psd-tools double-stroke-effects).
pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> {
let LayerContent::Shape(sh) = &layer.content else { return None };
let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside);
(sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path)
}
/// The shape a layer's effects are built from over `rect` (row-major): its content's alpha
/// (masks applied), completed for shapes with an [`effect_outline`] by the outline's coverage
/// where the fill is see-through. With `l` the alpha and `f` the fill's own opacity (the largest
/// alpha around), the shape is `l + (1 - f) × coverage`: Photoshop's own rasterization where the
/// fill is opaque (it can differ from ours by a fraction of a pixel: psd-tools shape-fx2), the
/// 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;
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]);
};
// Unmasked: the masks scale the shape, they aren't the fill's transparency.
let a: Vec<f32> = layer.surface().map(|s| surface_to_buffer(s, rect).px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
let cov = photocraft_vector::path_coverage(path, rect);
let mv = mask_vals(layer, rect, cx);
let (w, h) = (rect.width() as usize, rect.height() as usize);
let mut out = vec![0.0; n];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
// A pixel the outline covers is inside, whatever the fill's opacity.
let v = if cov[i] >= 1.0 - 1e-3 {
1.0
} else {
let mut f = 0.0f32;
for yy in y.saturating_sub(1)..(y + 2).min(h) {
for xx in x.saturating_sub(1)..(x + 2).min(w) {
f = f.max(a[yy * w + xx]);
}
}
(a[i] + (1.0 - f.min(1.0)) * cov[i]).clamp(0.0, 1.0)
};
out[i] = v * mask_k(&mv, i);
}
}
out
}
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
/// layer's pixel bounds: a shape layer's path bounds, truncated to whole pixels. Its rendered
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
@@ -583,12 +640,27 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
Some(buf)
}
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
/// The shape `layer`'s effect maps are built from over `rect` (row-major; for the GPU
/// compositor): its content's alpha (masks applied), joined with the [`effect_outline`] of a
/// filled shape. Zero for adjustment layers.
///
/// See also [`stroke_frame`].
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(doc, &patterns);
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
effect_shape(layer, rect, &cx)
}
/// The frame a gradient stroke `st` of `layer` is laid out in (`effects::FxMaps::stroke_frame`),
/// from the layer's cached effect maps; `None` when `st` isn't a gradient stroke of the layer.
/// For the GPU compositor.
pub fn stroke_frame(doc: &Document, layer: &Layer, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
if !matches!(st.paint, photocraft_doc::FxPaint::Gradient(_)) || !effects::has_effects(layer) {
return None;
}
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(doc, &patterns);
effect_maps(layer, &cx).stroke_frame(st)
}
pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
@@ -1013,10 +1085,18 @@ 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.
let Some(mut content) = render_content(layer, rect, cx) else { return };
// 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);
}
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,
@@ -1025,15 +1105,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
&maps,
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
cx.patterns,
vstroke,
);
return;
}
if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) {
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);
}
for (p, s) in content.px.iter_mut().zip(&stroke.px) {
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);
}
blend_into(backdrop, &content, layer.blend, opacity);
return;
@@ -1053,22 +1135,23 @@ pub fn text_gamma(layer: &Layer) -> f32 {
/// A stroked shape layer with visible clipped layers: Photoshop draws the shape's vector stroke
/// above the clipped layers, so the base content is the fill alone and the stroke is laid on top
/// after clipping. Returns (fill, stroke) buffers over `rect`, masks applied.
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer)> {
let LayerContent::Shape(sh) = &layer.content else { return None };
if sh.stroke.is_none() || !clipped.iter().any(|c| c.visible) {
/// after clipping. See [`split_parts`].
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
if !clipped.iter().any(|c| c.visible) {
return None;
}
split_parts(layer, rect, cx)
}
/// A stroked shape layer's (fill, vector stroke, mask values) over `rect`; `None` for other
/// layers. Fill and stroke are unmasked: the mask applies to them together. Layer effects paint
/// the fill's interior effects beneath the vector stroke (psd-tools stroke-composite: a colour
/// overlay leaves the stroke, the stroke effect covers it).
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
let LayerContent::Shape(sh) = &layer.content else { return None };
sh.stroke.as_ref()?;
let (fs, ss) = shape_split::split(sh, cx.canvas)?;
let mut f = surface_to_buffer(&fs, rect);
let mut s = surface_to_buffer(&ss, rect);
if let Some(m) = mask_vals(layer, rect, cx) {
for ((a, b), k) in f.px.iter_mut().zip(s.px.iter_mut()).zip(&m) {
a[3] *= k;
b[3] *= k;
}
}
Some((f, s))
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
}
// ---------------------------------------------------------------------------------------------
@@ -1176,7 +1259,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
let m = effects::margin(layer);
let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default();
let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1196,13 +1279,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
}
};
let entry = slot.get_or_init(|| {
let shape: Vec<f32> = if region.is_empty() {
Vec::new()
} else {
render_content(layer, region, cx)
.map(|b| b.px.iter().map(|p| p[3]).collect())
.unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
};
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
let bytes = maps.bytes();
// Counted exactly once, when the entry is built.
@@ -1268,7 +1345,14 @@ 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.
let Some(content) = render_content(layer, rect, cx) else { return };
let (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)
}
};
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
let maps = effect_maps(layer, cx);
effects::composite_with_effects_prepared(
@@ -1278,6 +1362,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
&maps,
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
cx.patterns,
vstroke,
);
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
if p[3] > 0.0 {
@@ -1328,5 +1413,7 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f
}
}
#[cfg(test)]
mod stroke_tests;
#[cfg(test)]
mod tests;
+51 -4
View File
@@ -11,7 +11,9 @@ use photocraft_doc::vector::ShapeLayer;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
type Entry = ((Surface, Surface), u64);
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
/// their tile addresses, part of the key, can't be reused).
type Entry = ((Surface, Surface), u64, Option<Surface>);
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
@@ -21,12 +23,20 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
const CAPACITY: usize = 32;
/// (fill only, stroke only) of a stroked shape, rendered over `canvas` (RGBA8, as the CPU
/// compositor's split always was). `None` without a stroke.
/// compositor's split always was). `None` without a stroke. Where the shape's pixels
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
sh.stroke.as_ref()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
if let Some(c) = &sh.cache {
format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h);
for (k, t) in c.tiles() {
(k.tx, k.ty, std::sync::Arc::as_ptr(t) as usize).hash(&mut h);
}
}
let key = h.finish();
{
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
@@ -40,7 +50,10 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
let fmt = PixelFormat::RGBA8;
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
let parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
if let Some(cache) = &sh.cache {
fit_stroke(&parts.0, &mut parts.1, cache, canvas);
}
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
if c.0.len() >= CAPACITY
&& let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0)
@@ -48,6 +61,40 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
c.0.remove(&old);
}
let tick = c.1;
c.0.insert(key, (parts.clone(), tick));
c.0.insert(key, (parts.clone(), tick, sh.cache.clone()));
Some(parts)
}
/// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke
/// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we
/// drew no stroke.
fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rect) {
// Where either has pixels (the stroke may reach past the shape's pixels, and vice versa).
let (cb, sb) = (crate::bounds::content_bounds(cache), crate::bounds::content_bounds(stroke));
let r = if cb.is_empty() {
sb
} else if sb.is_empty() {
cb
} else {
cb.union(&sb)
}
.intersect(&canvas);
if r.is_empty() {
return;
}
let n = r.width() as usize * r.height() as usize;
let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]);
fill.read_rgba_into(r, &mut f);
stroke.read_rgba_into(r, &mut s);
cache.read_rgba_into(r, &mut c);
let mut out = Vec::with_capacity(n * 4);
for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) {
let mut p = *sp;
if fp[3] < 1.0 - 1e-3 {
let a = ((cp[3] - fp[3]) / (1.0 - fp[3])).clamp(0.0, 1.0);
p = if sp[3] <= 0.0 { [cp[0], cp[1], cp[2], a] } else { [sp[0], sp[1], sp[2], a] };
}
out.extend_from_slice(&p);
}
stroke.write_region(r, &out);
}
+173
View File
@@ -0,0 +1,173 @@
//! Stroke effects on shape layers: the outline, the vector stroke's place, gradient frames.
use super::*;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
use photocraft_doc::{Effect, FxCommon, FxPaint, Gradient, GradientStyle, Layer, LayerContent, LayerMask, StrokeFx, StrokePosition};
use photocraft_geom::Size;
const E: f32 = 2.0 / 255.0;
fn close4(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
}
fn doc(w: u32, h: u32) -> Document {
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
}
fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
render(doc, Rect::from_xywh(x, y, 1, 1)).px[0]
}
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
}
fn blue() -> FxPaint {
FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))
}
/// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels.
fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option<ShapeStroke>) -> Layer {
let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]);
let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40)));
Layer::new("shape", LayerContent::Shape(sh))
}
#[test]
fn outline_share_treats_stroke_and_shape_as_disjoint_areas() {
// Outside the shape the stroke covers the pixel; inside it nothing.
assert_eq!(effects::outline_share(0.0, 0.0), 1.0);
assert_eq!(effects::outline_share(1.0, 0.3), 0.0);
// Beneath an opaque edge pixel the result ends opaque: share + l (1 - share) = 1 - cov + l.
let (cov, l) = (0.6, 0.6);
let s = effects::outline_share(cov, l);
assert!((s + l * (1.0 - s) - (1.0 - cov + l).min(1.0)).abs() < 1e-6);
// A faded fill keeps the edge part of the stroke only.
let s = effects::outline_share(0.6, 0.0);
assert!((s - 0.4).abs() < 1e-6);
assert_eq!(effects::outline_share(0.5, 1.0), 1.0);
assert!(effects::outline_share(f32::NAN, 0.5).is_finite() || effects::outline_share(f32::NAN, 0.5).is_nan());
}
#[test]
fn filled_shape_stroke_follows_the_outline_where_the_fill_fades_out() {
let mut d = doc(40, 40);
let mut transparent = Color::rgb(1.0, 0.0, 0.0);
transparent.alpha = 0.0;
let fade = Fill::Gradient {
stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, transparent)],
angle: 0.0,
scale: 1.0,
style: GradientStyle::Linear,
reverse: false,
};
let mut l = shape(10.0, 30.0, fade, None);
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue()), stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)))];
d.layers.push(l);
// The opaque side and the faded side both carry the outside stroke along the path.
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 8, 20));
assert!(close4(px(&d, 31, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 31, 20));
// ... and the inside stroke, even where the fill is transparent.
assert!(close4(px(&d, 28, 20), [0.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 28, 20));
// Between them the faded fill shows the background.
assert!(px(&d, 25, 20)[0] > 0.9 && px(&d, 25, 20)[1] > 0.5, "{:?}", px(&d, 25, 20));
}
#[test]
fn outside_stroke_fills_the_rest_of_a_shapes_edge_pixel() {
// The right edge at x = 30.5 covers half of column 30: an opaque fill and the stroke beneath
// it end opaque there (no seam of backdrop between them).
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
let mut l = shape(10.0, 30.5, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None);
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
d.layers.clear();
d.layers.push(l);
let p = px(&d, 30, 20);
assert!((p[3] - 1.0).abs() <= E, "{p:?}");
assert!((p[0] - 0.5).abs() <= 0.02 && (p[2] - 0.5).abs() <= 0.02, "{p:?}");
}
#[test]
fn vector_stroke_stays_above_interior_effects_and_below_the_stroke_effect() {
let mut d = doc(40, 40);
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
l.effects.items = vec![
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.0, 1.0) },
stroke(1.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
];
d.layers.push(l);
assert!(close4(px(&d, 10, 20), [1.0, 1.0, 0.0, 1.0]), "stroke effect on top: {:?}", px(&d, 10, 20));
assert!(close4(px(&d, 12, 20), [0.0, 1.0, 0.0, 1.0]), "vector stroke over the overlay: {:?}", px(&d, 12, 20));
assert!(close4(px(&d, 20, 20), [0.0, 0.0, 1.0, 1.0]), "overlay on the fill: {:?}", px(&d, 20, 20));
}
#[test]
fn a_stroked_shapes_mask_applies_to_fill_and_stroke_together() {
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
d.layers.clear();
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
let mut m = LayerMask::reveal_all();
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[0.5]);
l.mask = Some(m);
let mut clip = Layer::raster("clip", PixelFormat::RGBA8);
clip.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 40, 40), &[0.0, 0.0, 1.0, 1.0]);
clip.clipped = true;
d.layers.push(l.clone());
d.layers.push(clip.clone());
// On the vector stroke: the stroke's colour at the mask's 50 %, not 75 %.
let p = px(&d, 12, 20);
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
// With effects too.
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 1.0, 1.0) }];
d.layers = vec![l.clone()];
let p = px(&d, 12, 20);
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "without clipped layers: {p:?}");
d.layers = vec![l, clip];
let p = px(&d, 12, 20);
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
}
#[test]
fn gradient_stroke_spans_the_strokes_extent() {
let mut d = doc(40, 40);
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 90.0, ..Gradient::default() };
l.effects.items = vec![stroke(4.0, StrokePosition::Outside, FxPaint::Gradient(g))];
d.layers.push(l);
// The frame is the square grown by 3 px (7..33), laid out like every linear gradient
// (fill_layout::gradient_layout); the layer's own bounds would give other values.
let t_in = |frame: Rect, x: i32, y: i32| {
let (a, s, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 90.0, 1.0, (0.0, 0.0), frame);
effects::gradient_t(GradientStyle::Linear, a, s, false, o, frame, x as f32 + 0.5, y as f32 + 0.5)
};
let (stroke_frame, layer_frame) = (Rect::new(7, 7, 33, 33), Rect::new(10, 10, 30, 30));
for y in [7, 9, 31, 32] {
let p = px(&d, 20, y);
assert!((p[0] - t_in(stroke_frame, 20, y)).abs() <= 0.01, "row {y}: {p:?}");
}
assert!((t_in(stroke_frame, 20, 9) - t_in(layer_frame, 20, 9)).abs() > 0.05);
}
#[test]
fn several_strokes_stack_top_instance_first() {
// Multiple instances: the first listed is on top; an outer wider stroke shows past it.
let mut d = doc(60, 60);
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(Rect::new(20, 20, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
l.effects.items = vec![
stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0))),
stroke(5.0, StrokePosition::Outside, blue()),
stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
];
d.layers.push(l);
assert!(close4(px(&d, 19, 30), [0.0, 1.0, 0.0, 1.0]));
assert!(close4(px(&d, 16, 30), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 20, 30), [1.0, 1.0, 0.0, 1.0]));
assert!(close4(px(&d, 14, 30), [1.0; 4]));
}
+19 -1
View File
@@ -9,7 +9,8 @@
//! Path operations follow Photoshop: every subpath is filled on its own (with the path's
//! [`FillRule`]) and folded, in order, into the result with its [`PathOp`]; the first subpath
//! always acts as "combine" (see [`Path::effective_op`]; verified against Photoshop's pixels in
//! the PSD corpus). [`Path::inverted`] inverts the final area. A vector mask without subpaths
//! the PSD corpus). Subpaths marked [`PathOp::Join`] belong to the shape component before them
//! and are filled together with it (a custom shape's holes). [`Path::inverted`] inverts the final area. A vector mask without subpaths
//! reveals everything (Photoshop's fresh "Add Vector Mask").
use std::sync::Arc;
@@ -60,6 +61,10 @@ pub enum PathOp {
Intersect,
/// Exclude overlapping shapes (xor).
Exclude,
/// Part of the previous subpath's shape component (PSD operation -1 after a component's
/// first record): the component's subpaths are filled together, with the path's fill rule,
/// so inner subpaths wound the other way cut holes (custom shapes).
Join,
}
/// Winding rule used to fill each subpath.
@@ -131,6 +136,19 @@ impl Path {
pub fn effective_op(&self, i: usize) -> PathOp {
if i == 0 { PathOp::Combine } else { self.subpaths[i].op }
}
/// Shape components: ranges of subpaths filled together (a subpath starts a new component
/// unless it is a [`PathOp::Join`]), each folded in with its first subpath's
/// [`Path::effective_op`].
pub fn components(&self) -> Vec<std::ops::Range<usize>> {
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
for (i, s) in self.subpaths.iter().enumerate() {
match out.last_mut() {
Some(r) if s.op == PathOp::Join => r.end = i + 1,
_ => out.push(i..i + 1),
}
}
out
}
pub fn transform(&self, a: &Affine) -> Path {
let mut p = self.clone();
for s in &mut p.subpaths {
+2
View File
@@ -119,6 +119,7 @@ fn op_from(s: &str) -> Option<PathOp> {
"subtract" | "minus" => PathOp::Subtract,
"intersect" => PathOp::Intersect,
"exclude" | "xor" => PathOp::Exclude,
"join" => PathOp::Join,
_ => return None,
})
}
@@ -129,6 +130,7 @@ fn op_name(op: PathOp) -> &'static str {
PathOp::Subtract => "subtract",
PathOp::Intersect => "intersect",
PathOp::Exclude => "exclude",
PathOp::Join => "join",
}
}
+44 -7
View File
@@ -39,6 +39,7 @@ const F_TEX: u32 = 4u; // layer pixels live in `layer_tex`
const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`)
const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow)
const F_NO_LAYER: u32 = 32u; // effect merge: A already holds the layer (only the opacity mix)
const F_OUTLINE: u32 = 131072u; // outside strokes: filled shape outline (effects::outline_share)
const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside)
const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base
const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape
@@ -793,7 +794,10 @@ fn fx_color(d: vec2<i32>) -> vec4<f32> {
// Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base
// for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer
// before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha).
// before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the
// vector stroke C over A, relative to the shape B, at `opacity`; 5 A's colour with alpha
// `opacity` × A's alpha relative to B's (the layer within a filled shape's outline); 6 the
// outside-stroke coverage seed (0, A's alpha); 7 A with alpha joined with the effect shape (map).
@fragment
fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
@@ -802,10 +806,32 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
case 1: { return vec4(c.rgb, 1.0); }
case 2: { return vec4(c.rgb, select(0.0, op.opacity, c.a > INSIDE_EPS)); }
case 3: { return vec4(c.rgb, c.a * min(textureLoad(tex_b, p, 0).a, 1.0)); }
case 4: {
let a = textureLoad(tex_b, p, 0).a;
let s = textureLoad(tex_c, p, 0);
if (s.a <= 0.0 || a <= INSIDE_EPS) { return c; }
return composite(op.mode, c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0)), 1.0);
}
case 5: {
let a = textureLoad(tex_b, p, 0).a;
if (a <= INSIDE_EPS) { return vec4(c.rgb, 0.0); }
return vec4(c.rgb, op.opacity * min(c.a / a, 1.0));
}
case 6: { return vec4(0.0, c.a, 0.0, 0.0); }
case 7: { return vec4(c.rgb, max(c.a, map_value(doc_px(p), 0.0))); }
default: { return vec4(c.rgb, c.a * op.opacity); }
}
}
// effects::outline_share: an outside stroke's share of an edge pixel the outline covers `cov` of,
// beneath a layer of alpha `l` (disjoint areas).
fn outline_share(cov: f32, l: f32) -> f32 {
let outside = max(1.0 - clamp(cov, 0.0, 1.0), 0.0);
let rest = 1.0 - clamp(l, 0.0, 1.0);
if (rest <= 1e-6) { return 1.0; }
return min(outside / rest, 1.0);
}
// effects::paint: composite `colour × coverage × opacity` into A. B is the layer (its alpha `a`
// is the effect shape). Coverage `kind`: 0 the effect map, 1 full; then F_KNOCKOUT m × (1 − a k)
// (k = `p4.w`),
@@ -835,21 +861,32 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4<f32> {
}
// Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in
// `.r`, D (`layer_tex`) = accumulated premultiplied colour). Each stroke takes its band × opacity
// not yet covered above it; `kind` 0 accumulates that share of the stroke blended over A at full
// `.r` and, along a filled shape's outline (F_OUTLINE), the layer's alpha in `.g`, D
// (`layer_tex`) = accumulated premultiplied colour). Each stroke takes its band × opacity not yet
// covered above it; `kind` 0 accumulates that share of the stroke blended over A at full
// coverage, `kind` 1 the coverage.
@fragment
fn fs_fxstroke(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
let d = doc_px(p);
let a = textureLoad(tex_b, p, 0).a;
var k = map_value(d, 0.0);
if (a > INSIDE_EPS) { k = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u); }
let outline = (op.flags & F_OUTLINE) != 0u;
let first = (op.flags & F_FIRST) != 0u;
var cover = 0.0;
if (!first) { cover = textureLoad(tex_c, p, 0).r; }
var lay_a = 0.0;
if (!first || outline) {
let cv = textureLoad(tex_c, p, 0);
cover = cv.r;
lay_a = cv.g;
}
var k = map_value(d, 0.0);
if (outline) {
k = k * outline_share(a, lay_a);
} else if (a > INSIDE_EPS) {
k = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u);
}
let share = k * op.opacity * (1.0 - cover);
if (op.kind == 1) { return vec4(cover + share, 0.0, 0.0, 0.0); }
if (op.kind == 1) { return vec4(cover + share, lay_a, 0.0, 0.0); }
var acc = vec4(0.0);
if (!first) { acc = textureLoad(layer_tex, p, 0); }
if (share > 0.0) {
+7
View File
@@ -564,6 +564,13 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 {
let fr = photocraft_compose::fill_frame(layer, canvas);
(fr.x0, fr.y0, fr.x1, fr.y1, content.is_some()).hash(&mut h);
}
// A filled shape's effect shape follows its outline too.
LayerContent::Shape(sh) => {
std::mem::discriminant(&layer.content).hash(&mut h);
if photocraft_compose::effect_outline(layer).is_some() {
format!("{:?}", sh.path).hash(&mut h);
}
}
other => std::mem::discriminant(other).hash(&mut h),
}
for s in [content, mask].into_iter().flatten() {
+6 -2
View File
@@ -702,7 +702,7 @@ impl Compositor {
/// How far beyond a page cell the effect maps of `f` must be computed to be exact in it.
fn fx_apron(doc: &Document, f: &plan::FxLayer<'_>) -> i32 {
let vector_shape = matches!(f.layer.content, LayerContent::Shape(_));
let vector_shape = matches!(f.layer.content, LayerContent::Shape(_)) && photocraft_compose::effect_outline(f.layer).is_none();
let progs: Vec<fx::MapProgram> = f
.layer
.effects
@@ -1014,6 +1014,9 @@ impl Compositor {
let f = plan.fx.get(m.fx)?;
let key = (f.layer.id, paged.get(m.fx).copied().unwrap_or(false).then_some(cell));
let e = self.fx.get(&key)?;
if m.item == plan::SHAPE_MAP {
return Some((e.shape.view.clone(), e.region));
}
let t = e.progs.get(m.item)?.maps.get(m.map)?.as_ref()?;
Some((t.view.clone(), e.region))
}));
@@ -1445,7 +1448,8 @@ impl Compositor {
}
// Programs, and the distance fields they read (max reach per field).
let vector_shape = matches!(layer.content, LayerContent::Shape(_));
// Filled shapes stroke their outline (the effect shape); others estimate it.
let vector_shape = matches!(layer.content, LayerContent::Shape(_)) && photocraft_compose::effect_outline(layer).is_none();
let anchor = layer.effects.reference.unwrap_or((f64::from(f.bounds.x0), f64::from(f.bounds.y0)));
let progs: Vec<fx::MapProgram> =
layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect();
+136 -24
View File
@@ -290,6 +290,8 @@ pub struct DocCtx<'a> {
pub mode: photocraft_color::ColorMode,
/// Sample depth (adjustment results are rounded to it).
pub depth: photocraft_color::SampleType,
/// The document (effect geometry the CPU computes, e.g. gradient stroke frames).
pub doc: &'a Document,
}
impl<'a> DocCtx<'a> {
@@ -301,6 +303,7 @@ impl<'a> DocCtx<'a> {
patterns: &doc.patterns,
mode: doc.mode,
depth: doc.depth,
doc,
}
}
}
@@ -340,6 +343,9 @@ enum Cov {
pub const F_KNOCKOUT: u32 = 16;
/// Effect merge: A already holds the layer; only mix with the backdrop by the layer's opacity.
pub const F_NO_LAYER: u32 = 32;
/// Outside strokes along a filled shape's outline (`effects::outline_share`; the coverage slot
/// carries the layer's alpha in `.g`).
pub const F_OUTLINE: u32 = 131072;
/// Shape layer (outside strokes never show inside it).
pub const F_VECTOR: u32 = 64;
/// Merge onto an opaque clipping base, keeping its alpha.
@@ -371,6 +377,17 @@ pub const INIT_SCALE: i32 = 0;
pub const INIT_OPAQUE: i32 = 1;
pub const INIT_INSIDE: i32 = 2;
pub const INIT_ALPHA: i32 = 3;
/// The vector stroke C over A relative to the shape B, at `opacity`.
pub const INIT_VSTROKE: i32 = 4;
/// A's colour with alpha `opacity` × A's alpha relative to B's.
pub const INIT_RELATIVE: i32 = 5;
/// Outside-stroke coverage seed: (0, A's alpha).
pub const INIT_COVER: i32 = 6;
/// A with its alpha joined with the effect shape (the map).
pub const INIT_OUTLINE: i32 = 7;
/// `MapRef::item` of the layer's effect shape (`compose::layer_shape`) instead of an effect map.
pub const SHAPE_MAP: usize = usize::MAX;
impl<'a> Planner<'a> {
fn new(cx: DocCtx<'a>) -> Self {
@@ -612,25 +629,23 @@ impl<'a> Planner<'a> {
&& let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas)
{
// The vector stroke goes above the clipped layers: the fill is the clipping base,
// the stroke is laid over the clipped result (compose::shape_parts).
let part = |s: &mut Self, role: Role, surface: Surface| {
let mut p = Pass::new(Kernel::Content, 0);
p.mask = s.mask_use(layer);
p.color = photocraft_raster::to_rgba(&surface.format(), &surface.default_pixel());
if surface.tile_count() > 0 {
p.tex = Some(TexUse { layer: layer.id, role, surface: SurfaceRef::Derived(std::sync::Arc::new(surface)) });
}
s.emit(p)
};
let mut content = part(self, Role::Content, fill);
// the stroke is laid over the clipped result, then the masks apply to both
// (compose::shape_parts).
let mut content = self.shape_part(layer, Role::Content, fill);
for c in visible_clipped {
content = self.atop(c, content)?;
}
let stroke = part(self, Role::Stroke, stroke);
let stroke = self.shape_part(layer, Role::Stroke, stroke);
let mut p = Pass::new(Kernel::Blend, 0);
p.a = Some(content);
p.b = Some(stroke);
let content = self.emit(p);
let mut content = self.emit(p);
if let Some(m) = self.mask_use(layer) {
let mut p = Pass::new(Kernel::Mask, 0);
p.a = Some(content);
p.mask = Some(m);
content = self.emit(p);
}
let mut p = Pass::new(Kernel::Blend, 0);
p.a = Some(backdrop);
p.b = Some(content);
@@ -707,6 +722,16 @@ impl<'a> Planner<'a> {
dst
}
/// A stroked shape's fill or vector stroke alone (`compose::shape_split`), unmasked.
fn shape_part(&mut self, layer: &'a Layer, role: Role, surface: Surface) -> Slot {
let mut p = Pass::new(Kernel::Content, 0);
p.color = photocraft_raster::to_rgba(&surface.format(), &surface.default_pixel());
if surface.tile_count() > 0 {
p.tex = Some(TexUse { layer: layer.id, role, surface: SurfaceRef::Derived(std::sync::Arc::new(surface)) });
}
self.emit(p)
}
/// render_content for non-adjustment layers.
fn content(&mut self, layer: &'a Layer) -> Result<Slot, Unsupported> {
match &layer.content {
@@ -873,22 +898,70 @@ impl<'a> Planner<'a> {
let region = bounds::effect_region(layer, canvas);
let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas));
let clip = if bounds::transparent_outside(layer) { region } else { canvas };
let mut content = self.content(layer)?;
if !matches!(layer.content, LayerContent::Group(_))
&& let Some(p) = self.passes.last_mut()
{
p.clip = Some(clip);
// A stroked shape's vector stroke goes above its clipped layers and interior effects
// (compose::split_parts): the fill and the stroke unmasked, the masks applied after.
let split = match &layer.content {
LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas),
_ => None,
};
let (mut content, vstroke) = match split {
Some((fill, stroke)) => {
let f = self.shape_part(layer, Role::Content, fill);
let s = self.shape_part(layer, Role::Stroke, stroke);
(f, Some(s))
}
None => (self.content(layer)?, None),
};
for p in self.passes.iter_mut().rev().take(if vstroke.is_some() { 2 } else { 1 }) {
if !matches!(layer.content, LayerContent::Group(_)) {
p.clip = Some(clip);
}
}
for c in clipped {
content = self.atop(c, content)?;
}
let fx = self.fx.len();
self.fx.push(FxLayer { layer, region, bounds: sb });
let outline = photocraft_compose::effect_outline(layer).is_some();
let relative = outline || vstroke.is_some();
// `content` becomes the effect shape (B of every effect pass); `lay_src` the layer's colour.
let (lay_src, vstroke) = match vstroke {
Some(s) => {
let mut p = Pass::new(Kernel::Blend, 0);
p.a = Some(self.retain(content));
p.b = Some(self.retain(s));
p.clip = Some(clip);
let union = self.emit(p);
let m = self.mask_use(layer);
let masked = |me: &mut Self, slot: Slot| match &m {
Some(m) => {
let mut p = Pass::new(Kernel::Mask, 0);
p.a = Some(slot);
p.mask = Some(m.clone());
p.clip = Some(clip);
me.emit(p)
}
None => slot,
};
let fill = masked(self, content);
content = masked(self, union);
(fill, Some(masked(self, s)))
}
None => (self.retain(content), None),
};
if outline {
let mut p = Pass::new(Kernel::FxInit, 0);
p.a = Some(content);
p.adjust_kind = INIT_OUTLINE;
p.map = Some(MapRef { fx, item: SHAPE_MAP, map: 0 });
p.clip = Some(clip);
content = self.emit(p);
}
let items: Vec<&'a Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
// Linked patterns tile from the effects reference point (else the layer's top-left).
let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0)));
let vector_shape = matches!(layer.content, LayerContent::Shape(_));
let vector_shape = matches!(layer.content, LayerContent::Shape(_)) && !outline;
let map = |item: usize, map: usize| MapRef { fx, item, map };
let rev: Vec<(usize, &'a Effect)> = items.iter().copied().enumerate().rev().collect();
let init = |s: &mut Self, src: Slot, b: Option<Slot>, kind: i32, opacity: f32| {
@@ -936,8 +1009,12 @@ impl<'a> Planner<'a> {
// The layer: its colour at fill opacity inside its shape; interior effects are painted
// relative to the shape (coverage within it), then the shape's alpha applies.
let c = self.retain(content);
let mut l = init(self, c, None, INIT_INSIDE, layer.fill_opacity);
let mut l = if relative {
let c = self.retain(content);
init(self, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity)
} else {
init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity)
};
for &(_, e) in &rev {
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty())
@@ -972,13 +1049,33 @@ impl<'a> Planner<'a> {
l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb);
}
}
if let Some(s) = vstroke {
let mut p = Pass::new(Kernel::FxInit, 0);
p.a = Some(l);
p.b = Some(self.retain(content));
p.c = Some(s);
p.adjust_kind = INIT_VSTROKE;
p.opacity = layer.fill_opacity;
p.clip = Some(clip);
l = self.emit(p);
}
// Inside stroke parts are painted over the layer (bottom instance first).
for &(i, e) in &rev {
if let Effect::Stroke(st) = e {
let (in_w, _) = stroke_widths(st);
if in_w > 0.0 {
let paint = self.fx_paint(&st.paint, anchor);
l = self.paint(l, content, Cov::Map(map(i, 1), (in_w + 0.5).clamp(0.0, 1.0)), &paint, st.common.blend, st.common.opacity, F_GATE, clip, sb);
l = self.paint(
l,
content,
Cov::Map(map(i, 1), (in_w + 0.5).clamp(0.0, 1.0)),
&paint,
st.common.blend,
st.common.opacity,
F_GATE,
clip,
photocraft_compose::stroke_frame(self.cx.doc, layer, st).unwrap_or(sb),
);
}
}
}
@@ -1016,11 +1113,26 @@ impl<'a> Planner<'a> {
.collect();
if !outs.is_empty() {
let (mut acc, mut cover): (Option<Slot>, Option<Slot>) = (None, None);
if outline {
// The layer's alpha rides along the coverage (`effects::outline_share`).
let ll = self.retain(l);
cover = Some(init(self, ll, None, INIT_COVER, 1.0));
}
for (n, &(i, st)) in outs.iter().enumerate() {
let paint = self.fx_paint(&st.paint, anchor);
let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if n == 0 { F_FIRST } else { 0 };
let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if outline { F_OUTLINE } else { 0 } | if n == 0 { F_FIRST } else { 0 };
// Share × blended colour, then coverage (both read the coverage so far).
let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &paint, st.common.blend, st.common.opacity, flags, clip, sb);
let mut p = self.fx_pass_kernel(
Kernel::FxStroke,
w,
content,
&paint,
st.common.blend,
st.common.opacity,
flags,
clip,
photocraft_compose::stroke_frame(self.cx.doc, layer, st).unwrap_or(sb),
);
if matches!(paint, Paint::None) {
// A missing pattern paints nothing: the stroke's share keeps the result as is.
p.params[2][2] = 3.0;
+50
View File
@@ -967,6 +967,56 @@ fn layer_effects_on_shape_layers() {
}
}
#[test]
fn stroke_effects_on_filled_and_stroked_shapes() {
// compose::effect_outline (a fading gradient fill keeps its strokes along the path), several
// stroke instances with gradient frames, and a vector stroke above the interior effects with
// clipped layers and a mask.
let Some(mut g) = gpu() else { return };
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
for (fill_kind, vector_stroke, masked) in [(0, false, false), (1, false, true), (0, true, false), (1, true, true)] {
let mut d = fx_doc(90, 70, SampleType::U8);
let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]);
let mut clear = Color::rgb(0.9, 0.3, 0.1);
clear.alpha = 0.0;
let fill = if fill_kind == 0 {
Fill::Solid(Color::rgb(0.3, 0.6, 0.9))
} else {
Fill::Gradient {
stops: vec![(0.0, Color::rgb(0.9, 0.3, 0.1)), (1.0, clear)],
angle: 20.0,
scale: 1.0,
style: GradientStyle::Linear,
reverse: false,
}
};
let stroke_v = vector_stroke.then(|| ShapeStroke {
width: 3.0,
align: StrokeAlign::Inside,
paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)),
..ShapeStroke::default()
});
let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
let mut l = Layer::new("shape", LayerContent::Shape(sh));
if masked {
l.mask = Some(mask(Rect::new(0, 0, 90, 70), 7, 0.6));
}
l.effects.items = vec![
Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
Effect::Stroke(stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(gradient()))),
Effect::Stroke(stroke(3.0, StrokePosition::Inside, FxPaint::Gradient(gradient()))),
Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.7), color: Color::rgb(0.2, 0.2, 0.9) },
Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0)),
];
d.layers.push(l);
let mut c = noise_layer("clip", PixelFormat::RGBA8, Rect::new(30, 0, 60, 70), 9, 0.5);
c.clipped = true;
d.layers.push(c);
fx_check(&mut g, &d, &format!("fill {fill_kind} vector stroke {vector_stroke} masked {masked}"));
}
}
#[test]
fn channel_restrictions() {
let Some(mut g) = gpu() else { return };
+80 -1
View File
@@ -11,6 +11,7 @@
//! cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd # every file: max err, bad %, PASS/DIFF
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 dump prefix # raw f32 planes for offline fitting
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 bylayer # max error around each layer
//! SCALE=4 … # upscale the png
//! HIDE_ADJ=1 … # adjustment layers hidden
//! ONLY="name,name" … # only these top-level layers (+ the bottom one)
//! DUMP_FX=1 … # raw effects descriptors
@@ -89,7 +90,21 @@ fn main() {
println!(" alpha bounds ({x0},{y0})-({x1},{y1})");
}
if let photocraft_doc::LayerContent::Shape(sh) = &l.content {
println!(" shape path bounds {:?}", sh.path.control_bounds());
println!(
" shape path bounds {:?} subpaths {} rule {:?} inverted {} fill {} stroke {} outline {}",
sh.path.control_bounds(),
sh.path.subpaths.len(),
sh.path.fill_rule,
sh.path.inverted,
sh.fill.is_some(),
sh.stroke.is_some(),
photocraft_compose::effect_outline(l).is_some()
);
if std::env::var_os("PATHS").is_some() {
for s in &sh.path.subpaths {
println!(" subpath {:?} closed {} knots {:?}", s.op, s.closed, s.knots.iter().map(|k| (k.anchor.x, k.anchor.y)).collect::<Vec<_>>());
}
}
}
if let Some(vm) = &l.vector_mask {
println!(" vector mask bounds {:?}", vm.path.control_bounds());
@@ -133,6 +148,19 @@ fn main() {
}
}
}
if k == b"TySh"
&& let Some(Ok((vd, _))) = v.get(52..).map(photocraft_psd::descriptor::VersionedDescriptor::parse_prefix)
{
// Text bounds (text space) and the transform.
let t: Vec<f64> = (0..6).map(|i| f64::from_be_bytes(v[2 + i * 8..10 + i * 8].try_into().unwrap())).collect();
println!(" TySh transform {t:?}");
for key in ["bounds", "boundingBox"] {
if let Some(photocraft_psd::descriptor::Value::Descriptor(d)) = vd.descriptor.get(key) {
let n: Vec<String> = d.items.iter().map(|(k, v)| format!("{}={v:?}", String::from_utf8_lossy(k.as_bytes()))).collect();
println!(" TySh {key}: {}", n.join(" "));
}
}
}
if k == b"GdFl" {
// Skip the 4-byte version before the descriptor.
if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(v) {
@@ -213,6 +241,20 @@ fn main() {
}
}
}
// SCALE=n: nearest-neighbour upscale (small files).
let k: usize = std::env::var("SCALE").ok().and_then(|s| s.parse().ok()).unwrap_or(1).clamp(1, 16);
let (img, w, h) = if k > 1 {
let mut big = vec![0u8; w * 3 * k * h * k * 4];
for y in 0..h * k {
for x in 0..w * 3 * k {
let (s, d) = (((y / k) * w * 3 + x / k) * 4, (y * w * 3 * k + x) * 4);
big[d..d + 4].copy_from_slice(&img[s..s + 4]);
}
}
(big, w * k, h * k)
} else {
(img, w, h)
};
let image =
photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img)
.unwrap();
@@ -342,6 +384,43 @@ fn main() {
}
return;
}
if std::env::args().nth(3).as_deref() == Some("solve") {
// solve layer-name x0 y0 x1 y1: per pixel the layer's content alpha `l`, its shape's path
// coverage `cov`, Photoshop's alpha `A` and the alpha `s` an effect beneath the layer
// would need (A = s + l (1 - s)) over a transparent backdrop.
let name = std::env::args().nth(4).expect("layer name");
let a: Vec<i32> = (5..9).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect();
let w = doc.size.width as usize;
let l = doc.walk().into_iter().map(|t| t.2).find(|l| l.name == name).expect("layer");
let r = photocraft_geom::Rect::new(a[0], a[1], a[2], a[3]);
let cov = match &l.content {
photocraft_doc::LayerContent::Shape(sh) => photocraft_vector::path_coverage(&sh.path, r),
_ => vec![0.0; r.width() as usize * r.height() as usize],
};
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let la = l.surface().map_or(0.0, |s| s.pixel(x, y)[3]);
let c = cov[((y - r.y0) * r.width() as i32 + x - r.x0) as usize];
let p = merged[y as usize * w + x as usize];
let o = ours[y as usize * w + x as usize];
let s = if la < 1.0 { (p[3] - la) / (1.0 - la) } else { f32::NAN };
println!("({x:3},{y:3}) l {la:.3} cov {c:.3} A {:.3} ours {:.3} s {s:.3} ps {:?}", p[3], o[3], p.map(|v| (v * 255.0).round()));
}
}
return;
}
if std::env::args().nth(3).as_deref() == Some("line") {
// line x0 y0 x1 y1: every pixel on the segment, straight RGBA 0-255, ours | ps.
let w = doc.size.width as usize;
let a: Vec<i64> = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect();
let n = (a[2] - a[0]).abs().max((a[3] - a[1]).abs()).max(1);
for k in 0..=n {
let (x, y) = ((a[0] + (a[2] - a[0]) * k / n) as usize, (a[1] + (a[3] - a[1]) * k / n) as usize);
let (o, p) = (ours[y * w + x], merged[y * w + x]);
println!("({x:4},{y:4}) ours {:?} ps {:?}", o.map(|v| (v * 255.0).round()), p.map(|v| (v * 255.0).round()));
}
return;
}
if std::env::args().nth(3).as_deref() == Some("col") {
let (w, h) = (doc.size.width as usize, doc.size.height as usize);
let x = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(w / 2);
+18 -13
View File
@@ -25,6 +25,8 @@ fn op_from_psd(v: i16) -> PathOp {
0 => PathOp::Exclude,
2 => PathOp::Subtract,
3 => PathOp::Intersect,
// Continues the previous subpath's shape component.
-1 => PathOp::Join,
_ => PathOp::Combine,
}
}
@@ -35,6 +37,7 @@ fn op_to_psd(op: PathOp) -> i16 {
PathOp::Subtract => 2,
PathOp::Intersect => 3,
PathOp::Exclude => 0,
PathOp::Join => -1,
}
}
@@ -80,7 +83,9 @@ pub fn path_from_resource(data: &[u8], w: u32, h: u32) -> Option<Path> {
/// - without subpaths, the initial-fill record alone decides coverage (1 = everything);
/// - with subpaths the initial-fill record is ignored; a first component that subtracts starts
/// from a full canvas (the shape is inverted);
/// - a subpath with operation -1 continues the previous component (same operation).
/// - a subpath with operation -1 continues the previous component ([`PathOp::Join`]: filled
/// together with it, so subpaths wound the other way cut holes, as in psd-tools
/// stroke-effects' custom shapes).
///
/// The model applies the first subpath as "combine" and inverts the final result, so a leading
/// subtract becomes `inverted` with every later operation replaced by its complement dual
@@ -90,14 +95,8 @@ fn layer_path_from_records(p: &PathData, w: u32, h: u32) -> Path {
if path.subpaths.is_empty() {
return path;
}
let ops: Vec<i16> = p.subpaths().iter().map(|s| s.operation).collect();
let mut prev = PathOp::Combine;
for (s, &raw) in path.subpaths.iter_mut().zip(&ops) {
if raw == -1 {
s.op = prev;
}
prev = s.op;
}
// `path_from_records` maps -1 to `PathOp::Join` (the component's operation is its first
// subpath's); a leading -1 acts as the first component.
path.inverted = path.subpaths.first().is_some_and(|s| s.op == PathOp::Subtract);
if path.inverted {
for s in path.subpaths.iter_mut().skip(1) {
@@ -567,10 +566,11 @@ mod tests {
let rec = path_to_records(&p, 64, 64);
let back = path_from_records(&rec, 64, 64);
assert_eq!(back, p);
for op in [PathOp::Combine, PathOp::Subtract, PathOp::Intersect, PathOp::Exclude] {
for op in [PathOp::Combine, PathOp::Subtract, PathOp::Intersect, PathOp::Exclude, PathOp::Join] {
assert_eq!(op_from_psd(op_to_psd(op)), op);
}
assert_eq!(op_from_psd(-1), PathOp::Combine);
assert_eq!(op_from_psd(-1), PathOp::Join);
assert_eq!(op_from_psd(7), PathOp::Combine);
}
fn vmsk_with(initial_fill: bool, ops: &[i16]) -> Vec<u8> {
@@ -595,9 +595,14 @@ mod tests {
let (p, _) = path_from_vmsk(&vmsk_with(true, &[2, 1, 2, 0]), 100, 100).unwrap();
assert!(p.inverted);
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Subtract, PathOp::Combine, PathOp::Exclude]);
// -1 continues the previous component's operation.
// -1 continues the previous component (filled together with it, then subtracted).
let (p, _) = path_from_vmsk(&vmsk_with(false, &[1, 2, -1]), 100, 100).unwrap();
assert_eq!(p.subpaths[2].op, PathOp::Subtract);
assert_eq!(p.subpaths[2].op, PathOp::Join);
assert_eq!(p.components(), vec![0..1, 1..3]);
// Under a leading subtract the joined subpaths stay with their (first) component.
let (p, _) = path_from_vmsk(&vmsk_with(false, &[2, -1, 1]), 100, 100).unwrap();
assert!(p.inverted);
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Join, PathOp::Subtract]);
// No subpaths: a vector mask with initial fill reveals all, without hides all; a shape
// with initial fill covers everything.
let reveal = vector_mask_from_block(&vmsk_with(true, &[]), 100, 100).unwrap();
+4 -4
View File
@@ -145,13 +145,13 @@ struct Source {
roundtrip_floor: usize,
}
/// Hand-picked mix in `corpus/psd` (170 files: 133 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 145, roundtrip_floor: 169 };
/// Hand-picked mix in `corpus/psd` (170 files: 134 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 146, roundtrip_floor: 169 };
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 218 vs the merged image + 10 vs the thumbnail.
/// 219 vs the merged image + 10 vs the thumbnail.
const PSD_TOOLS: Source =
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 228, roundtrip_floor: 307 };
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 229, roundtrip_floor: 307 };
/// The corpus directory and whether floors are enforced; `None` when absent.
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
+82
View File
@@ -0,0 +1,82 @@
//! Multiple instances of an effect (Photoshop CC's several strokes / shadows / overlays) and
//! multi-subpath shape components survive a PSD round trip and render the same.
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::{Path, PathOp, ShapeLayer, Subpath};
use photocraft_doc::*;
use photocraft_geom::{Rect, Size};
use photocraft_io::{ExportOptions, export, import};
fn stroke(size: f32, position: StrokePosition, paint: FxPaint, opacity: f32) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint })
}
fn roundtrip(doc: &Document) -> Document {
let r = export(doc, "x.psd", &ExportOptions::default()).expect("export");
import("x.psd", &r.bytes).expect("import").document
}
fn max_diff(a: &Document, b: &Document) -> f32 {
let (x, y) = (photocraft_compose::flatten(a).px, photocraft_compose::flatten(b).px);
assert_eq!(x.len(), y.len());
x.iter().zip(&y).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0, f32::max)
}
#[test]
fn several_strokes_and_shadows_roundtrip() {
let mut d = Document::with_background("t", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("blob", PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(Rect::new(20, 14, 44, 34), &[0.8, 0.2, 0.1, 1.0]);
let g = Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))], angle: 30.0, ..Gradient::default() };
l.effects.items = vec![
stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)), 1.0),
stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(g), 0.8),
stroke(3.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 0.5, 0.0)), 1.0),
stroke(4.0, StrokePosition::Center, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)), 0.5),
Effect::default_drop_shadow(),
Effect::DropShadow(Shadow { distance: 9.0, angle: 45.0, use_global_light: false, ..shadow_of(Effect::default_drop_shadow()) }),
];
d.layers.push(l);
let back = roundtrip(&d);
let fx = &back.layers[1].effects.items;
let strokes: Vec<&StrokeFx> = fx.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
assert_eq!(strokes.len(), 4);
assert_eq!(
strokes.iter().map(|s| (s.size, s.position)).collect::<Vec<_>>(),
vec![(2.0, StrokePosition::Outside), (5.0, StrokePosition::Outside), (3.0, StrokePosition::Inside), (4.0, StrokePosition::Center)]
);
assert!(matches!(strokes[1].paint, FxPaint::Gradient(_)));
assert!((strokes[3].common.opacity - 0.5).abs() < 1e-3);
assert_eq!(fx.iter().filter(|e| matches!(e, Effect::DropShadow(_))).count(), 2);
let m = max_diff(&d, &back);
assert!(m <= 1.0 / 255.0 + 1e-5, "render changed by {m}");
}
fn shadow_of(e: Effect) -> Shadow {
match e {
Effect::DropShadow(s) => s,
_ => unreachable!("a drop shadow"),
}
}
#[test]
fn joined_shape_components_roundtrip() {
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let outer = Subpath::polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]);
let hole = Subpath::polygon(&[(12.0, 12.0), (12.0, 28.0), (28.0, 28.0), (28.0, 12.0)]).with_op(PathOp::Join);
let path = Path::new(vec![outer, hole]);
let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, d.bounds()));
let mut l = Layer::new("ring", LayerContent::Shape(sh));
l.effects.items = vec![stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), 1.0)];
d.layers.push(l);
// The hole is stroked too (the outline is the joined component).
let px = |doc: &Document, x: i32, y: i32| photocraft_compose::render(doc, Rect::from_xywh(x, y, 1, 1)).px[0];
assert!(px(&d, 20, 20)[1] > 0.9, "hole shows the background");
assert!(px(&d, 20, 13)[0] > 0.9 && px(&d, 20, 13)[1] < 0.1, "stroke inside the hole: {:?}", px(&d, 20, 13));
let back = roundtrip(&d);
let LayerContent::Shape(sh) = &back.layers[1].content else { panic!("shape layer") };
assert_eq!(sh.path.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), vec![PathOp::Combine, PathOp::Join]);
let m = max_diff(&d, &back);
assert!(m <= 1.0 / 255.0 + 1e-5, "render changed by {m}");
}
+3 -3
View File
@@ -39,9 +39,9 @@ pub const DEFAULT_TOLERANCE: f64 = 0.01;
/// folded with the subpath operations; open subpaths are closed implicitly.
pub fn fill_rasterizer(path: &Path, tol: f64) -> Rasterizer {
let mut r = Rasterizer::new(path.inverted);
for s in &path.subpaths {
let pl = flatten_subpath(s, tol);
r.add_component(&[pl.pts], s.op, path.fill_rule);
for c in path.components() {
let polys: Vec<Vec<(f64, f64)>> = path.subpaths[c.clone()].iter().map(|s| flatten_subpath(s, tol).pts).collect();
r.add_component(&polys, path.effective_op(c.start), path.fill_rule);
}
r
}
+2 -1
View File
@@ -122,7 +122,8 @@ impl Rasterizer {
// Photoshop applies the first component as "combine".
let op = if i == 0 { PathOp::Combine } else { c.op };
acc = match op {
PathOp::Combine => acc | v,
// A joined component (only reachable when built by hand) combines.
PathOp::Combine | PathOp::Join => acc | v,
PathOp::Subtract => acc & !v,
PathOp::Intersect => acc & v,
PathOp::Exclude => acc ^ v,
+28
View File
@@ -347,3 +347,31 @@ fn perf_6016() {
);
assert!(s16.tile_count() > 0);
}
#[test]
fn joined_subpaths_fill_as_one_component() {
// An outer square and an inner one wound the other way: as one component (PSD operation
// -1 after the first record) the inner one is a hole; as separate "combine" shapes it isn't.
let outer = Subpath::polygon(&[(2.0, 2.0), (18.0, 2.0), (18.0, 18.0), (2.0, 18.0)]);
let inner = Subpath::polygon(&[(6.0, 6.0), (6.0, 14.0), (14.0, 14.0), (14.0, 6.0)]);
let r = Rect::new(0, 0, 20, 20);
let joined = Path::new(vec![outer.clone(), inner.clone().with_op(PathOp::Join)]);
let v = cov(&joined, r);
assert_eq!(v[10 * 20 + 10], 0.0, "hole");
assert_eq!(v[4 * 20 + 4], 1.0);
assert!((area(&v) - (256.0 - 64.0)).abs() < 1e-3);
let separate = Path::new(vec![outer.clone(), inner.clone()]);
assert_eq!(cov(&separate, r)[10 * 20 + 10], 1.0);
assert_eq!(joined.components(), vec![0..2]);
assert_eq!(separate.components(), vec![0..1, 1..2]);
// A component after a joined one starts afresh with its own operation.
let cut = Subpath::polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)]).with_op(PathOp::Subtract);
let p = Path::new(vec![outer, inner.with_op(PathOp::Join), cut]);
assert_eq!(p.components(), vec![0..2, 2..3]);
let v = cov(&p, r);
assert_eq!(v[3 * 20 + 10], 0.0);
assert_eq!(v[5 * 20 + 10], 1.0);
// A lone joined subpath acts as the first component.
let lone = Path::new(vec![Subpath::polygon(&[(2.0, 2.0), (8.0, 2.0), (8.0, 8.0)]).with_op(PathOp::Join)]);
assert_eq!(cov(&lone, r)[3 * 20 + 6], 1.0);
}
+3 -3
View File
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 145/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 228/309, round trip ≥ 307/309 |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309 |
| [Tools](#tools) | 0 | 16 | 14 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 129 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
@@ -80,8 +80,8 @@ 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 | 145 | 169 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 228 | 307 |
| io corpus | `corpus/psd` | 170 | 146 | 169 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 |
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|