mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 21:54:05 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3dd9e1f8 |
No files matched your search
+125
-15
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in new issue
Block a user