mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 02:02:04 +01:00
A Gradient Overlay (and gradient strokes, linked patterns, linked bevel textures) is laid out over the layer's bounds. Painting grows those bounds, but the stroke commands reported only the stroke as damage, and the canvas's committed-stroke path repainted only what the live stroke had not shown. The canvas therefore kept the old gradient around earlier strokes: one gradient per stroke instead of one over the whole layer. Session dispatch now grows a command's reported damage by photocraft_compose::relayout_damage(before, after): the whole composite of any visible layer whose effects span its frame, when that frame moved. The canvas adds the same area to a committed live stroke. Strokes inside the existing bounds and frame-independent effects keep tight damage. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
90 lines
4.6 KiB
Rust
90 lines
4.6 KiB
Rust
//! A Gradient Overlay spans one gradient over the layer's current bounds, so a stroke that grows
|
|
//! the layer re-lays the gradient out over all of it (#4053). The damage a stroke reports must
|
|
//! cover that, or a canvas that redraws only the damage keeps the old gradient around the earlier
|
|
//! strokes: one gradient per stroke instead of one over the whole layer.
|
|
|
|
use photocraft_engine::Session;
|
|
use serde_json::json;
|
|
|
|
/// What a canvas that redraws only each edit's damage shows: the cold render before the edit,
|
|
/// with the edit's damage (grown by the effects' reach, as the canvas grows it) rendered again.
|
|
fn redraw_damage(shown: &mut photocraft_compose::Buffer, s: &Session) {
|
|
let st = s.active().unwrap();
|
|
let reach = st.doc.layers.iter().filter(|l| photocraft_compose::effects::has_effects(l)).map(photocraft_compose::effects::margin).max().unwrap_or(0);
|
|
let damage = st.last_damage.expect("a stroke reports bounded damage").inflate(reach).intersect(&st.doc.bounds());
|
|
let (w, h) = (st.doc.size.width, st.doc.size.height);
|
|
let patch = photocraft_compose::render_reduced_damage(&st.doc, w, h, damage);
|
|
for y in patch.rect.y0..patch.rect.y1 {
|
|
for x in patch.rect.x0..patch.rect.x1 {
|
|
let i = (y * w as i32 + x) as usize;
|
|
shown.px[i] = patch.get(x, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn session(effect: &str, params: serde_json::Value) -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 160, "height": 80, "background": "white"})).unwrap();
|
|
s.execute("layer.new.layer", json!({})).unwrap();
|
|
s.execute(effect, params).unwrap();
|
|
s
|
|
}
|
|
|
|
const GREY: &str = "#808080";
|
|
|
|
/// Each stroke redrawn over the last cold render equals a cold render after it.
|
|
fn strokes_redraw_like_a_cold_render(mut s: Session, strokes: &[serde_json::Value]) {
|
|
s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap();
|
|
for (i, p) in strokes.iter().enumerate() {
|
|
let st = s.active().unwrap();
|
|
let mut shown = photocraft_compose::render(&st.doc, st.doc.bounds());
|
|
s.execute("paint.stroke", p.clone()).unwrap();
|
|
redraw_damage(&mut shown, &s);
|
|
let st = s.active().unwrap();
|
|
let cold = photocraft_compose::render(&st.doc, st.doc.bounds());
|
|
let worst = shown.px.iter().zip(&cold.px).map(|(a, b)| (0..4).map(|c| (a[c] - b[c]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max);
|
|
assert!(worst < 1e-6, "after stroke {i} the redrawn canvas differs from a cold render by {worst}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_gradient_overlay_is_laid_out_again_after_each_stroke() {
|
|
let s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0}));
|
|
strokes_redraw_like_a_cold_render(
|
|
s,
|
|
&[
|
|
// Far from the first: the layer grows to the right and down.
|
|
json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY}),
|
|
// Up and to the left of both.
|
|
json!({"points": [[6, 6], [10, 8]], "size": 6, "hardness": 1.0, "color": GREY}),
|
|
// Erasing the far stroke shrinks the layer again.
|
|
json!({"points": [[110, 40], [150, 74]], "size": 40, "hardness": 1.0, "erase": true}),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stroke_inside_the_layer_keeps_its_own_damage() {
|
|
let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0}));
|
|
s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap();
|
|
// Within the layer's bounds: the gradient's frame stays, so only the stroke repaints.
|
|
let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap();
|
|
let d = &r["damage"];
|
|
let reported = photocraft_geom::Rect::new(
|
|
d[0].as_i64().unwrap() as i32,
|
|
d[1].as_i64().unwrap() as i32,
|
|
(d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32,
|
|
(d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32,
|
|
);
|
|
assert_eq!(s.active().unwrap().last_damage, Some(reported));
|
|
}
|
|
|
|
#[test]
|
|
fn effects_not_laid_out_over_the_layer_keep_the_stroke_damage() {
|
|
let mut s = session("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"}));
|
|
s.execute("paint.stroke", json!({"points": [[20, 20], [40, 30]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap();
|
|
s.execute("paint.stroke", json!({"points": [[120, 50], [140, 64]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap();
|
|
let d = s.active().unwrap().last_damage.unwrap();
|
|
assert!(d.x0 >= 100, "a Color Overlay repaints only the stroke: {d:?}");
|
|
}
|