mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 14:14:12 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc7144cf28 |
No files matched your search
@@ -287,12 +287,7 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
|
||||
/// Moves a surface by whole pixels (default pixel kept).
|
||||
pub fn translate_surface(s: &Surface, dx: i32, dy: i32) -> Surface {
|
||||
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
||||
let r = s.content_bounds();
|
||||
if !r.is_empty() {
|
||||
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
|
||||
}
|
||||
out
|
||||
s.translated(dx, dy, s.content_bounds())
|
||||
}
|
||||
|
||||
/// Keeps only the pixels inside `keep` (others become the default pixel).
|
||||
|
||||
@@ -290,12 +290,7 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
|
||||
/// `photocraft_algo::resample::translate_surface` with the content scan cached per tile (a Move
|
||||
/// drag shifts the same layers every frame; scanning them each time cost more than the copy).
|
||||
pub(crate) fn shift_surface(s: &photocraft_raster::Surface, dx: i32, dy: i32) -> photocraft_raster::Surface {
|
||||
let mut out = photocraft_raster::Surface::with_default(s.format(), &s.default_pixel());
|
||||
let r = photocraft_compose::bounds::content_bounds(s);
|
||||
if !r.is_empty() {
|
||||
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
|
||||
}
|
||||
out
|
||||
s.translated(dx, dy, photocraft_compose::bounds::content_bounds(s))
|
||||
}
|
||||
|
||||
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
|
||||
|
||||
@@ -15,10 +15,15 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
}
|
||||
|
||||
/// The region a layer's effect maps cover: its bounds grown by the effect reach, within the
|
||||
/// canvas grown likewise (`photocraft_compose::effect_maps`).
|
||||
/// canvas grown likewise (`photocraft_compose::effect_maps`) when the layer reaches far beyond
|
||||
/// it. A layer about the canvas's size keeps its whole region even where it overhangs the edge:
|
||||
/// then the region keeps its size while the layer moves (a Move tool drag), so its maps move with
|
||||
/// it instead of being rebuilt every frame (#761). The maps are exact either way.
|
||||
pub fn effect_region(layer: &Layer, canvas: Rect) -> Rect {
|
||||
let m = photocraft_compose::effects::margin(layer);
|
||||
layer_bounds(layer, canvas).inflate(m).intersect(&canvas.inflate(m))
|
||||
let (whole, window) = (layer_bounds(layer, canvas).inflate(m), canvas.inflate(m));
|
||||
let area = |r: Rect| u64::from(r.width()) * u64::from(r.height());
|
||||
if area(whole) <= area(window).saturating_add(area(window) / 4) { whole } else { whole.intersect(&window) }
|
||||
}
|
||||
|
||||
/// `photocraft_compose::transparent_outside`.
|
||||
|
||||
@@ -989,6 +989,31 @@ fn layer_effects_update_incrementally() {
|
||||
fx_check(&mut g, &d, "moved and painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_effects_move_with_a_layer_overhanging_the_canvas() {
|
||||
// A layer the size of the canvas with a drop shadow and a stroke, dragged with the Move tool
|
||||
// (#761): its pixels overhang the edge, yet each move reuses the maps instead of rebuilding
|
||||
// them, and the result still matches the CPU.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = fx_doc(160, 120, SampleType::U8);
|
||||
let mut l = noise_layer("full", PixelFormat::RGBA8, Rect::new(-6, -4, 166, 124), 3, 0.6);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(40, 30, 90, 70), &[0.0, 0.0, 0.0, 0.0]);
|
||||
l.effects.items = vec![
|
||||
Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 120.0, 6.0, 5.0, 0.0)),
|
||||
Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
|
||||
];
|
||||
d.layers.push(l);
|
||||
let first = fx_check(&mut g, &d, "initial");
|
||||
assert!(first.fx_programs > 0);
|
||||
for (dx, dy) in [(3, 2), (5, -4), (-9, 7)] {
|
||||
let s = d.layers[1].surface().unwrap();
|
||||
let moved = s.translated(dx, dy, s.content_bounds());
|
||||
*d.layers[1].surface_mut().unwrap() = moved;
|
||||
let s = fx_check(&mut g, &d, &format!("moved by ({dx}, {dy})"));
|
||||
assert_eq!(s.fx_programs, 0, "moved by ({dx}, {dy}): {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_effects_on_shape_layers() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
|
||||
@@ -503,6 +503,46 @@ impl Surface {
|
||||
out
|
||||
}
|
||||
|
||||
/// The surface moved by whole pixels: its encoded samples copied tile to tile (no float
|
||||
/// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves.
|
||||
/// `content` must hold every pixel that isn't the default one (`content_bounds`, say): only
|
||||
/// tiles it reaches are kept.
|
||||
pub fn translated(&self, dx: i32, dy: i32, content: Rect) -> Surface {
|
||||
let mut out = Surface { format: self.format, default_pixel: self.default_pixel.clone(), tiles: BTreeMap::new() };
|
||||
let sources = || self.tiles.iter().filter(move |(c, _)| !c.rect().intersect(&content).is_empty());
|
||||
if dx % TILE_SIZE == 0 && dy % TILE_SIZE == 0 {
|
||||
let (tx, ty) = (dx / TILE_SIZE, dy / TILE_SIZE);
|
||||
out.tiles = sources().map(|(c, t)| (TileCoord::new(c.tx.saturating_add(tx), c.ty.saturating_add(ty)), t.clone())).collect();
|
||||
return out;
|
||||
}
|
||||
let bpp = self.format.bytes_per_pixel();
|
||||
let moved = content.translate(dx, dy);
|
||||
let targets: std::collections::BTreeSet<TileCoord> = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect();
|
||||
for dc in targets {
|
||||
let mut tile = Tile::filled(&self.format, &self.default_pixel);
|
||||
let dr = dc.rect();
|
||||
let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg());
|
||||
for sc in sr.intersect(&content).tiles() {
|
||||
let Some(st) = self.tiles.get(&sc) else { continue };
|
||||
let part = sc.rect().intersect(&sr);
|
||||
let span = part.width() as usize * bpp;
|
||||
// Offsets inside the tiles (the target's computed wide: an offset near the i32
|
||||
// limits saturates `translate`, and such rows are skipped rather than misplaced).
|
||||
let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().map(|i| i * bpp);
|
||||
for y in part.y0..part.y1 {
|
||||
let src = at(i64::from(part.x0 - sc.tx * TILE_SIZE), i64::from(y - sc.ty * TILE_SIZE));
|
||||
let dst = at(i64::from(part.x0) + i64::from(dx) - i64::from(dr.x0), i64::from(y) + i64::from(dy) - i64::from(dr.y0));
|
||||
let (Some(src), Some(dst)) = (src, dst) else { continue };
|
||||
if let (Some(d), Some(s)) = (tile.data.get_mut(dst..dst + span), st.data.get(src..src + span)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.tiles.insert(dc, Arc::new(tile));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert to another pixel format (depth and/or model; colour models via [`convert_pixel`]).
|
||||
pub fn convert(&self, to: PixelFormat) -> Surface {
|
||||
let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel()));
|
||||
@@ -772,6 +812,37 @@ mod tests {
|
||||
assert!((v[0] - v[2]).abs() < 1e-6 && (v[3] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_matches_reading_the_moved_pixels() {
|
||||
// Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default.
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] {
|
||||
let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]);
|
||||
let r = Rect::new(-37, 200, 530, 300);
|
||||
let v: Vec<f32> = (0..r.width() * r.height() * fmt.channels() as u32).map(|i| (i % 251) as f32 / 250.0).collect();
|
||||
s.write_region(r, &v);
|
||||
for (dx, dy) in [(0, 0), (256, -512), (1, 0), (-3, 7), (300, -257), (-1000, 999)] {
|
||||
let t = s.translated(dx, dy, s.content_bounds());
|
||||
let moved = r.translate(dx, dy).inflate(3);
|
||||
assert_eq!(t.read_region(moved), s.read_region(moved.translate(-dx, -dy)), "{fmt:?} by ({dx}, {dy})");
|
||||
assert_eq!(t.default_pixel(), s.default_pixel());
|
||||
assert_eq!(t.to_interleaved(r.translate(dx, dy)), s.to_interleaved(r), "{fmt:?} by ({dx}, {dy}): not an exact copy");
|
||||
}
|
||||
// Whole tiles: the tiles are shared, not copied.
|
||||
let t = s.translated(512, -256, s.content_bounds());
|
||||
assert!(s.tiles().zip(t.tiles()).all(|((_, a), (_, b))| Arc::ptr_eq(a, b)));
|
||||
// Tiles without content (allocated, default pixels) are left behind.
|
||||
let mut padded = s.clone();
|
||||
padded.fill_rect(Rect::new(2000, 2000, 2100, 2100), &s.default_pixel());
|
||||
assert_eq!(padded.translated(5, 5, s.content_bounds()).tile_count(), s.translated(5, 5, s.content_bounds()).tile_count());
|
||||
}
|
||||
// Offsets at the limits don't panic.
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
s.write_pixel(5, 5, &[1.0]);
|
||||
let all = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
|
||||
let _ = s.translated(i32::MAX, i32::MIN, all);
|
||||
let _ = s.translated(i32::MIN + 1, 3, all);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn region_write_read_roundtrip(x in -600i32..600, y in -600i32..600, w in 1u32..90, h in 1u32..90, seed in any::<u32>()) {
|
||||
|
||||
@@ -127,6 +127,31 @@ impl Bench {
|
||||
Ok(ms(t))
|
||||
}
|
||||
|
||||
/// Frame `i` of a Move tool drag of `layer` (#128): the canvas shows the document with the
|
||||
/// layer at the pointer (`layer_multi_cmds::moved`) and recomposites where it was in the
|
||||
/// previous frame and where it is now (`move_ui::damage`).
|
||||
fn drag_frame(&self, s: &Session, layer: u64, i: usize) -> Res<f64> {
|
||||
let doc = s.active().ok_or("no document")?.doc.clone();
|
||||
let id = photocraft_doc::LayerId(layer);
|
||||
let all = photocraft_geom::Rect::new(i32::MIN / 2, i32::MIN / 2, i32::MAX / 2, i32::MAX / 2);
|
||||
let was = doc.layer(id).and_then(|l| photocraft_compose::change_bounds(l, all)).ok_or("no bounds")?;
|
||||
// Frame i shows offset (d, d / 2); the canvas showed the previous frame's.
|
||||
let (d, prev) = (3 * i as i32 + 1, if i == 0 { 0 } else { 3 * i as i32 - 2 });
|
||||
let t = Instant::now();
|
||||
let shown = photocraft_engine::layer_multi_cmds::moved(&doc, &[id], d, d / 2).map_err(|e| e.to_string())?;
|
||||
let damage = was.translate(prev, prev / 2).union(&was.translate(d, d / 2)).inflate(1).intersect(&doc.bounds());
|
||||
match &self.gpu {
|
||||
Some((g, rs)) => {
|
||||
g.refresh(doc.id.0, &shown, Some(damage), None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(&shown, damage));
|
||||
}
|
||||
}
|
||||
Ok(ms(t))
|
||||
}
|
||||
|
||||
fn gpu_bytes(&self, s: &Session) -> Option<u64> {
|
||||
let (g, _) = self.gpu.as_ref()?;
|
||||
let tex = s.active().and_then(|d| g.texture_info(d.doc.id.0)).map_or(0, |(_, b)| b);
|
||||
@@ -363,6 +388,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("move tool drag frame, styled layer", &mut s, reps, true, |b, s, i| b.drag_frame(s, target, i));
|
||||
b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?;
|
||||
@@ -400,6 +426,28 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
|
||||
// A layer filling the canvas, with a drop shadow (#761) and without (#128): dragged, it
|
||||
// overhangs the canvas edge.
|
||||
let full = (|| -> Res<u64> {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": "full"}))?;
|
||||
exec(&mut s, "select.all", json!({}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": "#4080c0"}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 12, "size": 16}))?;
|
||||
active_layer(&s)
|
||||
})();
|
||||
match full {
|
||||
Ok(full) => {
|
||||
let _ = b.refresh(&s, true);
|
||||
b.time("move tool drag frame, full-canvas layer with drop shadow", &mut s, reps, true, |b, s, i| b.drag_frame(s, full, i));
|
||||
let _ = exec(&mut s, "layer.layerStyle.clear", json!({"layer": full}));
|
||||
let _ = b.refresh(&s, true);
|
||||
b.time("move tool drag frame, full-canvas layer", &mut s, reps, true, |b, s, i| b.drag_frame(s, full, i));
|
||||
let _ = exec(&mut s, "layer.delete", json!({"layer": full}));
|
||||
}
|
||||
Err(e) => b.errors.push(("full-canvas styled layer".into(), e)),
|
||||
}
|
||||
|
||||
// A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and
|
||||
// again (the same source).
|
||||
let (lw, lh) = sz.big_layer;
|
||||
|
||||
Reference in new issue
Block a user