mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user