Skip distant TSL-off stroke layers during composition (#2473)

* Skip distant TSL-off stroke layers during composition

* Narrow the TSL-off stroke shortcut: shape outlines count, other clipping bases keep the empty skip

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 13:58:04 -04:00
1 parent 865c8a15f8
commit edd9fe12d7
3 files changed
+179 -2

No files matched your search

@@ -0,0 +1,44 @@
//! Reproduce tall-document TSL-off stroke-effect compositing from issue #2102.
//! Run in release mode: `cargo run --release -p photocraft-compose --example bench_tsly_stroke`.
use std::error::Error;
use std::time::Instant;
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_compose::render_bands;
use photocraft_doc::{Document, Effect, FxCommon, FxPaint, Layer, Size, StrokeFx, StrokePosition};
use photocraft_geom::Rect;
const WIDTH: u32 = 1920;
const HEIGHT: u32 = 103_675;
const BAND_ROWS: i32 = 4352;
fn document_with_strokes(layer_count: usize) -> Result<Document, Box<dyn Error>> {
let mut document = Document::new("#2102 tall TSL-off strokes", Size::new(WIDTH, HEIGHT), ColorMode::Rgb, SampleType::U8);
let step = HEIGHT / (layer_count as u32 + 1);
for i in 0..layer_count {
let y = step.saturating_mul(i as u32 + 1);
let mut layer = Layer::raster("Outside stroke", PixelFormat::RGBA8);
let surface = layer.surface_mut().ok_or("raster layer has no surface")?;
surface.fill_rect(Rect::new(360, y as i32, 1560, y as i32 + 120), &[0.2, 0.5, 0.9, 1.0]);
layer.effects.items.push(Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)),
}));
layer.advanced.transparency_shapes = false;
document.layers.push(layer);
}
Ok(document)
}
fn main() -> Result<(), Box<dyn Error>> {
for layer_count in [3, 10] {
let document = document_with_strokes(layer_count)?;
let start = Instant::now();
render_bands(&document, document.bounds(), BAND_ROWS, |_| -> Result<(), &'static str> { Ok(()) })?;
println!("{WIDTH}×{HEIGHT}, {layer_count} TSL-off 6 px outside strokes: {:.3} s", start.elapsed().as_secs_f64());
}
Ok(())
}
+88
View File
@@ -272,6 +272,94 @@ fn inside_stroke() -> Effect {
})
}
fn outside_stroke() -> Effect {
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
})
}
#[test]
fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
use photocraft_doc::BlendRange;
let modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Difference];
for depth in DEPTHS {
for position in [StrokePosition::Outside, StrokePosition::Inside, StrokePosition::Center] {
for blend in modes {
for fill in [0.0, 0.5, 1.0] {
for blend_if in [false, true] {
let mut d = Document::with_background("t", Size::new(80, 768), ColorMode::Rgb, depth, Color::WHITE);
let mut l = solid(&d, "stroke", Rect::new(28, 318, 52, 342), BLUE);
l.effects.items = vec![Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 6.0,
position,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
})];
l.blend = blend;
l.fill_opacity = fill;
l.advanced.transparency_shapes = false;
if blend_if {
l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]);
}
d.layers.push(l);
let rect = Rect::new(0, 0, 80, 768);
let whole = render_tiled(&d, rect, 1024);
let tiled = render_tiled(&d, rect, 64);
assert_eq!(whole.px, tiled.px, "{depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
let mut bands = Vec::new();
render_bands(&d, rect, 256, |band| -> Result<(), ()> {
bands.push(band);
Ok(())
})
.unwrap();
let mut band_pixels = Vec::with_capacity(whole.px.len());
for band in bands {
band_pixels.extend(band.px);
}
assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
}
}
}
}
}
}
#[test]
fn shapeless_stroke_skip_rejects_advanced_blending_and_masks() {
use photocraft_doc::{BlendRange, Path, Subpath, VectorMask};
let d = doc(SampleType::U8, true);
let mut l = solid(&d, "stroke", Rect::new(2, 2, 8, 8), BLUE);
l.effects.items = vec![outside_stroke()];
l.advanced.transparency_shapes = false;
assert!(shapeless_stroke_bounds(&l).is_some());
l.advanced.knockout = Knockout::Deep;
assert!(shapeless_stroke_bounds(&l).is_none(), "knockout changes the backdrop outside the content");
l.advanced.knockout = Knockout::None;
l.advanced.blend_interior = true;
assert!(shapeless_stroke_bounds(&l).is_none(), "Blend Interior effects remain on the uncropped path");
l.advanced.blend_interior = false;
l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]);
assert!(shapeless_stroke_bounds(&l).is_none(), "Blend If remains on the uncropped path");
l.blend_if = Default::default();
l.mask = Some(LayerMask::hide_all());
assert!(shapeless_stroke_bounds(&l).is_none(), "enabled masks remain on the uncropped path");
l.mask = None;
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])])));
assert!(shapeless_stroke_bounds(&l).is_none(), "enabled vector masks remain on the uncropped path");
}
#[test]
fn layer_mask_hides_effects() {
for depth in DEPTHS {
+47 -2
View File
@@ -851,7 +851,8 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool {
// Effects reach at most `margin` beyond the layer's pixels (when it is transparent
// outside them): render tiles away from a small text layer skip it entirely.
let canvas = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4);
return transparent_outside(layer) && layer_bounds(layer, canvas).inflate(effects::margin(layer)).intersect(&rect).is_empty();
let bounds = shapeless_stroke_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas));
return transparent_outside(layer) && bounds.inflate(effects::margin(layer)).intersect(&rect).is_empty();
}
match &layer.content {
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => match layer.surface() {
@@ -862,6 +863,47 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool {
}
}
/// For the narrow `tsly=0` case where only local stroke effects are enabled, the effects cannot
/// contribute outside the stroke reach from the layer's pixels. Other `tsly=0` effects can cover
/// the full layer, and advanced blending can change the backdrop independently of those pixels.
fn shapeless_stroke_bounds(layer: &Layer) -> Option<Rect> {
use photocraft_doc::Knockout;
if layer.advanced.transparency_shapes
|| layer.advanced.knockout != Knockout::None
|| layer.advanced.blend_interior
|| layer.clipped
|| !layer.blend_if.is_default()
|| layer.mask.as_ref().is_some_and(|m| m.enabled)
|| layer.vector_mask.as_ref().is_some_and(|m| m.enabled)
|| !transparent_outside(layer)
|| !effects::maps_are_local(layer)
{
return None;
}
if !layer.effects.enabled
|| !layer.effects.items.iter().any(|e| e.enabled())
|| layer.effects.items.iter().any(|e| e.enabled() && !matches!(e, photocraft_doc::Effect::Stroke(_)))
{
return None;
}
match &layer.content {
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => {
let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds);
// A filled shape's effects follow its outline, also where its fill is transparent (as
// in `layer_bounds`).
Some(match effect_outline(layer).and_then(|_| paint_bounds(layer)) {
Some(p) if !b.is_empty() => b.union(&p),
Some(p) => p,
None => b,
})
}
_ => None,
}
}
/// Blending Options › Channels as per-channel weights over display RGB (1 = the layer's result,
/// 0 = the backdrop's value kept), or `None` when every channel blends. RGB documents map R, G, B
/// directly and a grayscale document's single channel covers all three; other modes composite in
@@ -1114,7 +1156,10 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
let rect = backdrop.rect;
if empty_in(layer, rect) {
// A TSL-off stroke base that is transparent here can still have visible clipped siblings;
// keep the full clipping path for it instead of the stroke-bounds shortcut. Other bases keep
// the usual skip (their clipped layers vanish with them).
if (clipped.is_empty() || shapeless_stroke_bounds(layer).is_none()) && empty_in(layer, rect) {
return;
}
let opacity = layer.opacity * layer.fill_opacity;