mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 04:06:53 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46888b4bf5 |
No files matched your search
@@ -737,6 +737,55 @@ fn restore_channels(out: &mut Buffer, before: &Buffer, w: [f32; 3]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `layer`'s composite (its pixels and effects; a group's visible children with theirs) can
|
||||
/// be non-transparent, or `None` when it may draw anywhere (a surface with an opaque default
|
||||
/// pixel). Outside it, compositing the layer leaves the backdrop unchanged in every blend mode.
|
||||
/// Adjustment layers count as empty: they only change pixels already there (which is what an
|
||||
/// isolated group needs; see [`change_bounds`] for the document-level question).
|
||||
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
if !transparent_outside(layer) {
|
||||
return None;
|
||||
}
|
||||
let own = match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {
|
||||
let mut acc = Rect::EMPTY;
|
||||
for c in g.children.iter().filter(|c| c.visible) {
|
||||
let b = composite_bounds(c, canvas)?;
|
||||
if !b.is_empty() {
|
||||
acc = if acc.is_empty() { b } else { acc.union(&b) };
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
LayerContent::Adjustment(_) => Rect::EMPTY,
|
||||
_ => layer_bounds(layer, canvas),
|
||||
};
|
||||
if own.is_empty() {
|
||||
return Some(Rect::EMPTY);
|
||||
}
|
||||
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
|
||||
Some(own.inflate(m).intersect(&canvas))
|
||||
}
|
||||
|
||||
/// The document pixels that showing, hiding, moving or restyling `layer` can change, or `None`
|
||||
/// for anywhere: an adjustment layer (and a pass-through group holding one) reaches everything
|
||||
/// beneath it. Clipped layers above stay within their base's bounds, so they are covered too;
|
||||
/// effects of groups around the layer can reach further (callers grow the rect by that reach).
|
||||
pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
fn reaches_below(l: &Layer) -> bool {
|
||||
match &l.content {
|
||||
// Clipped, it changes only its base's pixels.
|
||||
LayerContent::Adjustment(_) => !l.clipped,
|
||||
LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if matches!(layer.content, LayerContent::Adjustment(_)) || reaches_below(layer) {
|
||||
return None;
|
||||
}
|
||||
composite_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
|
||||
/// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown
|
||||
/// by their reach.
|
||||
|
||||
@@ -468,6 +468,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" };
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
s.edit(label, |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
@@ -510,6 +513,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
@@ -1071,7 +1077,7 @@ fn combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) ->
|
||||
/// Move a layer's pixels, linked mask and type by whole pixels (vectors move via
|
||||
/// `vector_cmds::translate_vectors`).
|
||||
pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
use photocraft_algo::resample::translate_surface;
|
||||
use crate::layer_multi_cmds::shift_surface as translate_surface;
|
||||
// Linked patterns in the layer's effects move with it.
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
|
||||
|
||||
@@ -246,17 +246,125 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("no active layer".into()));
|
||||
}
|
||||
s.edit("Move", |doc, _| {
|
||||
let ids = top_level(doc, &with_links(doc, &roots));
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let ids = s.edit("Move", |doc, _| {
|
||||
let ids = move_targets(doc, &roots);
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::NoLayer(roots[0]));
|
||||
}
|
||||
let moves: Vec<_> = ids.into_iter().map(|id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)
|
||||
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)?;
|
||||
Ok(ids)
|
||||
})?;
|
||||
note_damage(s, &before, &ids);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The layers a move of `roots` takes along: their linked layers, without layers whose group
|
||||
/// is also moving (the group carries them).
|
||||
pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
|
||||
top_level(doc, &with_links(doc, roots))
|
||||
}
|
||||
|
||||
/// `doc` with the layers `ids` ([`move_targets`]) moved by whole pixels, as `layer.translate`
|
||||
/// leaves them: the Move tool's live preview while it drags. Type, shape and smart-object pixels
|
||||
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
|
||||
/// copying the moving pixels only.
|
||||
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
|
||||
let mut out = doc.clone();
|
||||
for &id in ids {
|
||||
let l = out.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.locks.position || l.locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
shift_shown(doc, l, dx, dy);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `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
|
||||
}
|
||||
|
||||
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
|
||||
fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
use self::shift_surface as translate_surface;
|
||||
let a = photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy));
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
|
||||
}
|
||||
if let Some(m) = l.mask.as_mut().filter(|m| m.linked) {
|
||||
m.surface = translate_surface(&m.surface, dx, dy);
|
||||
}
|
||||
if let Some(vm) = l.vector_mask.as_mut().filter(|v| v.linked) {
|
||||
vm.path = vm.path.transform(&a);
|
||||
}
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = translate_surface(s, dx, dy),
|
||||
LayerContent::Text(t) => {
|
||||
t.transform = a.mul(&t.transform);
|
||||
if let Some(c) = &mut t.cache {
|
||||
*c = translate_surface(c, dx, dy);
|
||||
}
|
||||
}
|
||||
LayerContent::Shape(sh) => {
|
||||
// The rendered shape is cut at the canvas: one that reaches past it renders again.
|
||||
let canvas = doc.bounds();
|
||||
let inside = sh.cache.as_ref().is_some_and(|c| {
|
||||
let b = photocraft_compose::bounds::content_bounds(c);
|
||||
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
|
||||
});
|
||||
crate::vector_cmds::transform_shape(sh, &a);
|
||||
match &mut sh.cache {
|
||||
Some(c) if inside => *c = translate_surface(c, dx, dy),
|
||||
_ => crate::vector_cmds::refresh_shape(doc, sh),
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy),
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(ab) = &mut g.artboard {
|
||||
ab.rect = ab.rect.translate(dx, dy);
|
||||
}
|
||||
g.children.iter_mut().for_each(|c| shift_shown(doc, c, dx, dy));
|
||||
}
|
||||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents
|
||||
/// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere.
|
||||
pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Option<Rect> {
|
||||
let mut out = Rect::EMPTY;
|
||||
for d in [before, after] {
|
||||
let canvas = d.bounds();
|
||||
for id in ids {
|
||||
// A layer gone from one side (never for a move) changes who knows what.
|
||||
let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?;
|
||||
if !b.is_empty() {
|
||||
out = if out.is_empty() { b } else { out.union(&b) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Report [`layers_damage`] as the active document's last change, so the canvas recomposites
|
||||
/// only that (grown by the effect reach around it) instead of everything.
|
||||
pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) {
|
||||
let Some(st) = s.active_mut() else { return };
|
||||
if before.size != st.doc.size {
|
||||
return;
|
||||
}
|
||||
st.last_damage = layers_damage(before, &st.doc, ids);
|
||||
}
|
||||
|
||||
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
|
||||
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
|
||||
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
|
||||
@@ -734,6 +842,54 @@ mod tests {
|
||||
selected(s)
|
||||
}
|
||||
|
||||
fn damage(s: &Session) -> Option<Rect> {
|
||||
s.active().unwrap().last_damage
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_layer_props_report_only_their_area() {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 5, "dy": -3})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(10, 7, 25, 20)), "where the layer was and is");
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "visible": false})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "opacity": 0.5, "blend": "Multiply"})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||||
// Clipping changes reach the layers around: everything.
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "clipped": true})).unwrap();
|
||||
assert_eq!(damage(&s), None);
|
||||
// An adjustment layer changes everything beneath it.
|
||||
let adj = s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": adj, "visible": false})).unwrap();
|
||||
assert_eq!(damage(&s), None);
|
||||
// The Background (opaque everywhere): everything.
|
||||
let bg = doc(&s).layers[0].id;
|
||||
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 0.5})).unwrap();
|
||||
let all = doc(&s).bounds();
|
||||
assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s));
|
||||
// Bad params still fail cleanly.
|
||||
assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err());
|
||||
assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_move_preview_matches_the_move() {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let ids = move_targets(&before, &[a]);
|
||||
let shown = moved(&before, &ids, 7, 4).unwrap();
|
||||
s.execute("layer.translate", json!({"layer": a.0, "dx": 7, "dy": 4})).unwrap();
|
||||
assert_eq!(layer_bounds(shown.layer(a).unwrap()), Some(bounds(&s, a)));
|
||||
// Locked layers don't move in the preview either.
|
||||
let mut locked = (*before).clone();
|
||||
locked.layer_mut(a).unwrap().locks.position = true;
|
||||
assert!(moved(&locked, &ids, 1, 1).is_err());
|
||||
assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err());
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, ids: &[LayerId]) {
|
||||
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||||
for id in &ids[1..] {
|
||||
|
||||
@@ -403,10 +403,10 @@ pub(crate) fn snap_affine(a: Affine) -> Affine {
|
||||
pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
|
||||
sm.transform = Affine::translate(dx as f64, dy as f64).mul(&sm.transform);
|
||||
if let Some(c) = &mut sm.cache {
|
||||
*c = translate_surface(c, dx, dy);
|
||||
*c = crate::layer_multi_cmds::shift_surface(c, dx, dy);
|
||||
}
|
||||
if let Some(m) = &mut sm.filter_mask {
|
||||
m.surface = translate_surface(&m.surface, dx, dy);
|
||||
m.surface = crate::layer_multi_cmds::shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@ pub struct SnapTargets {
|
||||
/// Bounds of a layer for snapping: what Free Transform would show (`None` for empty layers,
|
||||
/// groups and maskless adjustment layers).
|
||||
pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> {
|
||||
let l = doc.layer(id)?;
|
||||
rect_of(doc, doc.layer(id)?)
|
||||
}
|
||||
|
||||
fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) {
|
||||
return None;
|
||||
}
|
||||
@@ -118,7 +121,7 @@ impl SnapTargets {
|
||||
if !l.visible || exclude.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = layer_rect(doc, l.id) {
|
||||
if let Some(r) = rect_of(doc, l) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ fn bad(msg: impl Into<String>) -> EngineError {
|
||||
}
|
||||
|
||||
/// Document-space bounds a transform of `layer` starts from (what Free Transform frames).
|
||||
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
|
||||
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
|
||||
let content = match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().map(|l| transform_bounds(doc, l)).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, Surface::content_bounds),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
|
||||
};
|
||||
let content = if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| m.surface.content_bounds()) } else { content };
|
||||
let content =
|
||||
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content };
|
||||
match &doc.selection {
|
||||
Some(sel) if !layer.is_group() => content.intersect(&sel.content_bounds()),
|
||||
_ => content,
|
||||
|
||||
@@ -26,6 +26,11 @@ pub fn transparent_outside(layer: &Layer) -> bool {
|
||||
photocraft_compose::transparent_outside(layer)
|
||||
}
|
||||
|
||||
/// `photocraft_compose::composite_bounds`: where compositing the layer can change anything.
|
||||
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
photocraft_compose::composite_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+85
-19
@@ -639,18 +639,72 @@ impl<'a> Planner<'a> {
|
||||
return Ok(self.emit(p));
|
||||
}
|
||||
|
||||
// A layer that covers little of the canvas composites only over its bounds and is
|
||||
// copied back into the backdrop (#125): a layout with hundreds of small text and shape
|
||||
// layers otherwise shades every layer over every chunk.
|
||||
let clip = self.local_clip(layer);
|
||||
if clip.is_some_and(|c| c.is_empty()) {
|
||||
// Nothing to draw (an empty layer, or a group of them).
|
||||
for c in &visible_clipped {
|
||||
self.check_blend_if(c)?;
|
||||
}
|
||||
return Ok(backdrop);
|
||||
}
|
||||
let start = self.passes.len();
|
||||
let mut content = self.content(layer)?;
|
||||
for c in visible_clipped {
|
||||
content = self.atop(c, content)?;
|
||||
}
|
||||
if let Some(clip) = clip {
|
||||
// Everything that built the layer's content only matters inside its bounds.
|
||||
for p in &mut self.passes[start..] {
|
||||
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
|
||||
p.clip = Some(clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.a = Some(backdrop);
|
||||
p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop });
|
||||
p.b = Some(content);
|
||||
p.mode = layer.blend;
|
||||
p.opacity = opacity;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
Ok(self.emit(p))
|
||||
p.clip = clip;
|
||||
let merged = self.emit(p);
|
||||
match clip {
|
||||
Some(clip) => Ok(self.write_back(backdrop, merged, clip)),
|
||||
None => Ok(merged),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rect a plain layer composites over: its composite bounds when they cover at most half
|
||||
/// the canvas (beyond that, compositing everywhere costs less than the copy back).
|
||||
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
||||
let canvas = self.cx.canvas;
|
||||
let b = bounds::composite_bounds(layer, canvas)?;
|
||||
(b.width() as u64 * b.height() as u64 * 2 <= canvas.width() as u64 * canvas.height() as u64).then_some(b)
|
||||
}
|
||||
|
||||
/// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop);
|
||||
/// returns the new backdrop. Consumes both.
|
||||
fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot {
|
||||
let dst = if self.refs[backdrop as usize] == 1 {
|
||||
backdrop
|
||||
} else {
|
||||
let n = self.alloc();
|
||||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||||
p.a = Some(backdrop);
|
||||
self.passes.push(p);
|
||||
self.release(backdrop);
|
||||
n
|
||||
};
|
||||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||||
p.a = Some(merged);
|
||||
p.clip = Some(clip);
|
||||
self.passes.push(p);
|
||||
self.release(merged);
|
||||
dst
|
||||
}
|
||||
|
||||
/// render_content for non-adjustment layers.
|
||||
@@ -1058,22 +1112,7 @@ impl<'a> Planner<'a> {
|
||||
self.release(content);
|
||||
|
||||
// Write the clipped result back into the backdrop (in place when nothing else holds it).
|
||||
let dst = if self.refs[backdrop as usize] == 1 {
|
||||
backdrop
|
||||
} else {
|
||||
let n = self.alloc();
|
||||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||||
p.a = Some(backdrop);
|
||||
self.passes.push(p);
|
||||
self.release(backdrop);
|
||||
n
|
||||
};
|
||||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||||
p.a = Some(merged);
|
||||
p.clip = Some(clip);
|
||||
self.passes.push(p);
|
||||
self.release(merged);
|
||||
Ok(dst)
|
||||
Ok(self.write_back(backdrop, merged, clip))
|
||||
}
|
||||
|
||||
fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> {
|
||||
@@ -1437,8 +1476,35 @@ mod tests {
|
||||
d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format()));
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
// Empty layers draw nothing.
|
||||
assert_eq!(p.passes.len(), 1 + 2);
|
||||
for (i, l) in d.layers.iter_mut().skip(1).enumerate() {
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.slots <= 3, "{} slots", p.slots);
|
||||
assert_eq!(p.passes.len(), 1 + 11 * 2);
|
||||
// The background, then each small layer over its bounds and copied back.
|
||||
assert_eq!(p.passes.len(), 1 + 2 + 10 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_layers_composite_only_over_their_bounds() {
|
||||
let mut d = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("small", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 12, 20, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let mut group = Layer::group("g", vec![l]);
|
||||
group.blend = BlendMode::Normal;
|
||||
d.layers.push(group);
|
||||
let p = plan(&d).unwrap();
|
||||
let r = Rect::new(10, 12, 20, 30);
|
||||
// The group's content, its blend and the copy back are all limited to the layer.
|
||||
assert!(p.passes.iter().filter(|p| matches!(p.kernel, Kernel::Content | Kernel::Blend)).skip(2).all(|p| p.clip == Some(r)), "{:?}", p.passes);
|
||||
let last = p.passes.last().unwrap();
|
||||
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(r)));
|
||||
// A layer that covers most of the canvas composites everywhere.
|
||||
d.layers[1].children_mut().unwrap()[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 60, 60), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.passes.iter().all(|p| p.clip.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -429,6 +429,60 @@ use photocraft_doc::{
|
||||
|
||||
/// An anti-aliased blob (disc plus a soft-edged bar and a hole) with partially transparent parts,
|
||||
/// so effects see real edge coverage, concavities and interior alpha.
|
||||
/// Small layers composite only over their bounds and are copied back into the backdrop (#125):
|
||||
/// every blend mode, clipped layers, isolated and pass-through groups (with adjustments, masks,
|
||||
/// opacity), layers partly off the canvas and over several chunks must still match the CPU.
|
||||
#[test]
|
||||
fn small_layers_composite_over_their_bounds() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let small = |seed: u32, x: i32, y: i32, mode: BlendMode| {
|
||||
let mut l = noise_layer("s", PixelFormat::RGBA8, Rect::from_xywh(x, y, 23, 17), seed, 0.0);
|
||||
l.blend = mode;
|
||||
l.opacity = 0.85;
|
||||
l
|
||||
};
|
||||
let mut d = base_doc(200, 150);
|
||||
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
|
||||
let i = i as i32;
|
||||
d.layers.push(small(100 + i as u32, (i * 29) % 190 - 8, (i * 37) % 140 - 5, mode));
|
||||
}
|
||||
// A clipping base with a raster and an adjustment clipped to it.
|
||||
let base = small(200, 120, 90, BlendMode::Normal);
|
||||
let mut c1 = noise_layer("c1", PixelFormat::RGBA8, Rect::new(100, 80, 190, 140), 201, 0.0);
|
||||
c1.clipped = true;
|
||||
c1.blend = BlendMode::Multiply;
|
||||
let mut c2 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||||
c2.clipped = true;
|
||||
d.layers.extend([base, c1, c2]);
|
||||
// An isolated group with a mask, an adjustment and a nested pass-through group.
|
||||
let inner = Layer::group("inner", vec![small(300, 40, 100, BlendMode::Screen), small(301, 55, 110, BlendMode::Normal)]);
|
||||
let mut iso = Layer::group(
|
||||
"iso",
|
||||
vec![
|
||||
small(302, 30, 95, BlendMode::Normal),
|
||||
Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)),
|
||||
inner,
|
||||
small(303, 70, 120, BlendMode::Overlay),
|
||||
],
|
||||
);
|
||||
iso.blend = BlendMode::Normal;
|
||||
iso.opacity = 0.75;
|
||||
iso.mask = Some(mask(Rect::new(25, 90, 100, 145), 304, 1.0));
|
||||
d.layers.push(iso);
|
||||
// A pass-through group with opacity around small layers.
|
||||
let mut pt = Layer::group("pt", vec![small(305, 150, 10, BlendMode::Normal), small(306, 160, 20, BlendMode::Difference)]);
|
||||
pt.opacity = 0.6;
|
||||
d.layers.push(pt);
|
||||
check(&mut g, &d, "small layers");
|
||||
// The same over several compositor chunks.
|
||||
let mut big = base_doc(1500, 1200);
|
||||
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
|
||||
let i = i as i32;
|
||||
big.layers.push(small(400 + i as u32, 1010 + (i % 4) * 5, 1010 + (i / 4) * 5, mode));
|
||||
}
|
||||
check(&mut g, &big, "small layers across chunks");
|
||||
}
|
||||
|
||||
fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer {
|
||||
let mut l = Layer::raster(name, fmt);
|
||||
let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Frame times of the whole app on a designer's layout document (#125, #128): ~175 layers in
|
||||
//! nested groups (pass-through and isolated), 60 text layers in several fonts and sizes, stroked
|
||||
//! shapes, smart objects, drop shadows / strokes / gradient overlays, adjustment layers and masks,
|
||||
//! 4000×3000 8-bit RGB (`tests/support/layout_doc.rs`), saved as PSD and reopened through the real
|
||||
//! import path.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example layout_bench -- [--reps 9] [--json out.json] [--direct] [--navigator] [--size 4000x3000]
|
||||
//! ```
|
||||
//!
|
||||
//! The real `PhotocraftApp` runs in an offscreen egui_kittest harness on wgpu (the GPU canvas
|
||||
//! path, with the device the app requests). Interactions go through real input where the harness
|
||||
//! can reach it (key shortcuts, pointer drags and clicks on the canvas) and otherwise through the
|
||||
//! command a panel runs (`layer.select`, `layer.setProps`), then the frames that follow. Each row
|
||||
//! is the median / p90 / max of a frame's time over the repetitions; `PHOTOCRAFT_GPU_SYNC` makes
|
||||
//! the canvas wait for the GPU, so a frame's time includes its composite. Move drags report the
|
||||
//! press, the pointer-move frames and the release (the commit) separately. `--direct` skips the
|
||||
//! PSD round trip; `--navigator` also shows the Navigator panel; `--spin idle|select|tool|move-
|
||||
//! pixel|move-text|move-shape|move-group|move-smart|move-headline` repeats one interaction for 20 s
|
||||
//! for a sampling profiler.
|
||||
|
||||
#[path = "../tests/support/layout_doc.rs"]
|
||||
mod layout_doc;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type H = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// One frame, timed.
|
||||
fn frame(h: &mut H) -> f64 {
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
ms(t)
|
||||
}
|
||||
|
||||
struct Rows {
|
||||
reps: usize,
|
||||
rows: Vec<(String, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
/// A row from frame times already taken.
|
||||
fn add(&mut self, name: &str, v: Vec<f64>) {
|
||||
let mut v: Vec<f64> = v.into_iter().filter(|t| t.is_finite()).collect();
|
||||
if v.is_empty() {
|
||||
return;
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
let (med, p90, max) = (v[v.len() / 2], v[(v.len() * 9 / 10).min(v.len() - 1)], v[v.len() - 1]);
|
||||
let flag = if med > 16.0 { " <-- over 16 ms" } else { "" };
|
||||
println!("{name:<46} median {med:>8.2} ms p90 {p90:>8.2} max {max:>8.2}{flag}");
|
||||
self.rows.push((name.to_string(), med, p90, max));
|
||||
}
|
||||
|
||||
/// `f` performs one interaction and returns the worst frame it took (ms).
|
||||
fn time(&mut self, name: &str, mut f: impl FnMut() -> f64) {
|
||||
let v = (0..self.reps).map(|_| f()).collect();
|
||||
self.add(name, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` frames (returns the worst).
|
||||
fn settle(h: &mut H, n: usize) -> f64 {
|
||||
(0..n).map(|_| frame(h)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Screen position of document point (x, y) in the main canvas.
|
||||
fn screen(h: &H, x: f64, y: f64) -> Pos2 {
|
||||
let app = h.state();
|
||||
let idx = app.session.active_index().unwrap_or(0);
|
||||
let v = &app.ui.views[idx];
|
||||
let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect);
|
||||
ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }.to_screen(x as f32, y as f32)
|
||||
}
|
||||
|
||||
fn select(h: &mut H, id: LayerId) {
|
||||
let _ = h.state_mut().run("layer.select", json!({"layer": id.0}));
|
||||
}
|
||||
|
||||
/// A drag on the canvas from document point `from` by `d`, `steps` pointer moves one frame apart.
|
||||
/// Returns the frame times: hover, press, the moves, release, one more.
|
||||
fn drag(h: &mut H, from: [f64; 2], d: [f64; 2], steps: usize) -> Vec<f64> {
|
||||
let p0 = screen(h, from[0], from[1]);
|
||||
let mut times = Vec::new();
|
||||
h.event(Event::PointerMoved(p0));
|
||||
times.push(frame(h));
|
||||
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
times.push(frame(h));
|
||||
for i in 1..=steps {
|
||||
let f = i as f64 / steps as f64;
|
||||
let p = screen(h, from[0] + d[0] * f, from[1] + d[1] * f);
|
||||
h.event(Event::PointerMoved(p));
|
||||
times.push(frame(h));
|
||||
}
|
||||
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
|
||||
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
times.push(frame(h));
|
||||
times.push(frame(h));
|
||||
times
|
||||
}
|
||||
|
||||
/// Rows for Move drags (frame times from [`drag`]): the press, the pointer moves, the release.
|
||||
fn move_rows(rows: &mut Rows, label: &str, drags: &[Vec<f64>]) {
|
||||
let pick = |f: &dyn Fn(&[f64]) -> Vec<f64>| drags.iter().flat_map(|d| f(d)).collect::<Vec<f64>>();
|
||||
rows.add(&format!("Move {label}: press"), pick(&|d| d.get(1).copied().into_iter().collect()));
|
||||
rows.add(&format!("Move {label}: drag frames"), pick(&|d| d.get(2..d.len().saturating_sub(2)).unwrap_or_default().to_vec()));
|
||||
rows.add(&format!("Move {label}: release (commit)"), pick(&|d| d.len().checked_sub(2).and_then(|i| d.get(i)).copied().into_iter().collect()));
|
||||
}
|
||||
|
||||
fn contains(l: &photocraft_doc::Layer, t: LayerId) -> bool {
|
||||
l.id == t || l.children().is_some_and(|c| c.iter().any(|k| contains(k, t)))
|
||||
}
|
||||
|
||||
/// A point where `id` (or one of its layers, for a group) is the topmost layer with pixels.
|
||||
fn point_on(h: &H, id: LayerId) -> Option<[f64; 2]> {
|
||||
let st = h.state().session.active()?;
|
||||
let l = st.doc.layer(id)?;
|
||||
let b = photocraft_engine::layer_multi_cmds::layer_bounds(l)?;
|
||||
let (cx, cy) = ((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2);
|
||||
for r in 0..60 {
|
||||
for (dx, dy) in [(0, 0), (r, 0), (-r, 0), (0, r), (0, -r)] {
|
||||
let (x, y) = (cx + dx * 3, cy + dy * 3);
|
||||
if photocraft_engine::pick_cmds::layers_at(&st.doc, x, y).first().is_some_and(|&t| contains(l, t)) {
|
||||
return Some([x as f64 + 0.5, y as f64 + 0.5]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
reexec_with_gpu_sync();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(9).max(3);
|
||||
let mut spec = layout_doc::Spec::full();
|
||||
if let Some((w, hh)) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))) {
|
||||
(spec.width, spec.height) = (w, hh);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (doc, _) = layout_doc::build(spec);
|
||||
let (n, texts) = layout_doc::count(&doc);
|
||||
println!("built {}×{} layout: {n} layers, {texts} text layers in {:.0} ms", spec.width, spec.height, ms(t));
|
||||
let doc = if args.iter().any(|a| a == "--direct") {
|
||||
doc
|
||||
} else {
|
||||
let t = Instant::now();
|
||||
let psd = photocraft_io::export(&doc, "layout.psd", &Default::default()).expect("psd export").bytes;
|
||||
let t_save = ms(t);
|
||||
let t = Instant::now();
|
||||
let r = photocraft_io::import("layout.psd", &psd).expect("psd import");
|
||||
println!("PSD: {:.1} MB, save {t_save:.0} ms, open {:.0} ms ({} warnings)", psd.len() as f64 / 1e6, ms(t), r.warnings.len());
|
||||
// PSD export writes engine-made smart objects as pixels (no SoLd yet): convert them back,
|
||||
// as a Photoshop-made file would have them.
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.open_document(r.document, None);
|
||||
let smart = |l: &photocraft_doc::Layer| l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group());
|
||||
let ids: Vec<u64> = s.active().map(|st| st.doc.walk().iter().filter(|(_, _, l)| smart(l)).map(|(_, _, l)| l.id.0).collect()).unwrap_or_default();
|
||||
for id in ids {
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id})).expect("convert");
|
||||
}
|
||||
(*s.active().expect("doc").doc).clone()
|
||||
};
|
||||
// Layer ids change through the PSD round trip: find the layers by name and kind.
|
||||
let find = |name: &str, pred: &dyn Fn(&LayerContent) -> bool| -> LayerId {
|
||||
doc.walk().iter().find(|(_, _, l)| l.name == name && pred(&l.content)).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}"))
|
||||
};
|
||||
let pixel = find("Tile 1", &|c| matches!(c, LayerContent::Raster(_)));
|
||||
let text = find("Product 1 — Oak", &|c| matches!(c, LayerContent::Text(_)));
|
||||
let shape = find("Badge", &|c| matches!(c, LayerContent::Shape(_)));
|
||||
let group = find("Card 1", &|c| matches!(c, LayerContent::Group(_)));
|
||||
let smart = find("Product 1", &|c| matches!(c, LayerContent::Smart(_)));
|
||||
let hero = find("Hero", &|c| matches!(c, LayerContent::Group(_)));
|
||||
let headline = find("Make things that last", &|c| matches!(c, LayerContent::Text(_)));
|
||||
let products = find("Products", &|c| matches!(c, LayerContent::Group(_)));
|
||||
|
||||
let navigator = args.iter().any(|a| a == "--navigator");
|
||||
let ppp: f32 = arg(&args, "--ppp").and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let t = Instant::now();
|
||||
let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp);
|
||||
let mut h: H = builder.wgpu_setup(photocraft_ui_egui::gpu_canvas::wgpu_setup()).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
rs.device.set_device_lost_callback(|r, m| eprintln!("GPU device lost ({r:?}): {m}"));
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.session.open_document(doc, Some("layout.psd".into()));
|
||||
app.sync_views();
|
||||
app.ui.panels.navigator = navigator;
|
||||
app
|
||||
});
|
||||
settle(&mut h, 4);
|
||||
{
|
||||
let p = &h.state().perf;
|
||||
println!(
|
||||
"open + first frames: {:.0} ms (canvas: gpu {}, fallback {:?}, first composite {} {:.0} ms)",
|
||||
ms(t),
|
||||
p.gpu,
|
||||
p.gpu_fallback,
|
||||
p.last_refresh,
|
||||
p.composite_ms
|
||||
);
|
||||
}
|
||||
settle(&mut h, 4);
|
||||
|
||||
if let Some(dir) = arg(&args, "--shots") {
|
||||
shots(&mut h, &dir, ppp, [group, headline]);
|
||||
return;
|
||||
}
|
||||
if let Some(what) = arg(&args, "--spin") {
|
||||
spin(&mut h, &what, [pixel, text, shape, group, smart, headline]);
|
||||
return;
|
||||
}
|
||||
let mut rows = Rows { reps, rows: Vec::new() };
|
||||
rows.time("idle frame", || frame(&mut h));
|
||||
rows.time("hover over the canvas", || {
|
||||
let p = screen(&h, 1000.0, 1000.0);
|
||||
h.event(Event::PointerMoved(p));
|
||||
let a = frame(&mut h);
|
||||
h.event(Event::PointerMoved(p + egui::vec2(7.0, 3.0)));
|
||||
a.max(frame(&mut h))
|
||||
});
|
||||
|
||||
// Selecting layers: the command a Layers panel row click runs, then the frames after (the
|
||||
// Layers and Properties panels follow the new layer).
|
||||
let picks = [pixel, text, shape, group, smart, hero, headline];
|
||||
let mut k = 0;
|
||||
rows.time("select a layer (Layers panel)", || {
|
||||
k += 1;
|
||||
let t = Instant::now();
|
||||
select(&mut h, picks[k % picks.len()]);
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
// A real click on a Layers panel row (rows carry their layer's name for accessibility).
|
||||
let names = ["Gallery", "Products", "Hero", "Header", "Footer"];
|
||||
rows.time("click a Layers panel row", || {
|
||||
k += 1;
|
||||
// The row is the widest node with the name (its disclosure triangle has it too).
|
||||
let row = h.query_all_by_label(names[k % names.len()]).map(|n| n.rect()).max_by(|a, b| a.width().total_cmp(&b.width()));
|
||||
let Some(p) = row.map(|r| r.center()) else { return f64::NAN };
|
||||
h.event(Event::PointerMoved(p));
|
||||
let a = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
let b = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
a.max(b).max(frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
rows.time("open / close a group (Layers panel)", || {
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setExpanded", json!({"layer": products.0}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
let tools = [Key::V, Key::B, Key::M, Key::T, Key::U, Key::E];
|
||||
rows.time("switch tool (shortcut)", || {
|
||||
k += 1;
|
||||
h.key_press(tools[k % tools.len()]);
|
||||
frame(&mut h).max(frame(&mut h))
|
||||
});
|
||||
let mut vis = false;
|
||||
for (label, id) in [("text layer", text), ("group", group)] {
|
||||
rows.time(&format!("toggle visibility ({label})"), || {
|
||||
vis = !vis;
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": vis}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": true}));
|
||||
settle(&mut h, 3);
|
||||
}
|
||||
let mut op = 1.0;
|
||||
select(&mut h, shape);
|
||||
settle(&mut h, 2);
|
||||
rows.time("opacity change (Layers / Properties)", || {
|
||||
op = if op > 0.6 { 0.5 } else { 0.9 };
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": shape.0, "opacity": op}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
|
||||
// Move tool drags. Auto-Select off (Photoshop's default): the selected layer moves.
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = false;
|
||||
settle(&mut h, 3);
|
||||
for (label, id) in [("pixel", pixel), ("text", text), ("shape", shape), ("group", group), ("smart object", smart)] {
|
||||
select(&mut h, id);
|
||||
settle(&mut h, 3);
|
||||
let Some(from) = point_on(&h, id) else {
|
||||
println!("move {label}: no visible pixel found");
|
||||
continue;
|
||||
};
|
||||
// Back and forth, so every other drag returns the layer where it started.
|
||||
let mut commit = Vec::new();
|
||||
let drags: Vec<Vec<f64>> = (0..reps)
|
||||
.map(|i| {
|
||||
let d = drag(&mut h, from, if i.is_multiple_of(2) { [37.0, 23.0] } else { [-37.0, -23.0] }, 8);
|
||||
commit.push(h.state().perf.command_ms);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
move_rows(&mut rows, label, &drags);
|
||||
rows.add(&format!(" of which layer.translate ({label})"), commit);
|
||||
}
|
||||
// Auto-Select on: a click on the canvas selects the layer under the pointer; a press on a
|
||||
// type layer that isn't selected drags it.
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
if let (Some(a), Some(b)) = (point_on(&h, text), point_on(&h, pixel)) {
|
||||
let mut i = 0u32;
|
||||
rows.time("click the canvas (auto-select a layer)", || {
|
||||
i += 1;
|
||||
let p = screen(&h, if i.is_multiple_of(2) { a[0] } else { b[0] }, if i.is_multiple_of(2) { a[1] } else { b[1] });
|
||||
h.event(Event::PointerMoved(p));
|
||||
let f0 = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
let f1 = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
let f2 = frame(&mut h);
|
||||
let f3 = frame(&mut h);
|
||||
f0.max(f1).max(f2).max(f3)
|
||||
});
|
||||
}
|
||||
if let Some(from) = point_on(&h, headline) {
|
||||
let drags: Vec<Vec<f64>> = (0..reps)
|
||||
.map(|i| {
|
||||
select(&mut h, pixel);
|
||||
settle(&mut h, 2);
|
||||
drag(&mut h, from, if i.is_multiple_of(2) { [25.0, 0.0] } else { [-25.0, 0.0] }, 8)
|
||||
})
|
||||
.collect();
|
||||
move_rows(&mut rows, "auto-select type", &drags);
|
||||
}
|
||||
|
||||
println!("perf spans: {:?}", h.state().perf.spans);
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let r: Vec<Value> = rows.rows.iter().map(|(n, med, p90, max)| json!({"name": n, "median_ms": med, "p90_ms": p90, "max_ms": max})).collect();
|
||||
let report = json!({"width": spec.width, "height": spec.height, "layers": n, "text_layers": texts, "rows": r});
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&report).expect("json")).expect("write json");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--shots DIR`: screenshots of the whole app while a Move drag is under way and after the
|
||||
/// release, fitted on screen and at 100 %, for a card group and the hero headline (`{DIR}/
|
||||
/// move-{what}-{view}-{phase}@{ppp}x.png`).
|
||||
fn shots(h: &mut H, dir: &str, ppp: f32, [group, headline]: [LayerId; 2]) {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let save = |h: &mut H, name: &str| match h.render() {
|
||||
Ok(img) => {
|
||||
let p = format!("{dir}/{name}@{ppp}x.png");
|
||||
match img.save(&p) {
|
||||
Ok(()) => println!("wrote {p}"),
|
||||
Err(e) => eprintln!("{p}: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("{name}: render failed: {e}"),
|
||||
};
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = false;
|
||||
for (what, id) in [("card", group), ("headline", headline)] {
|
||||
for view in ["fit", "100"] {
|
||||
select(h, id);
|
||||
if let Some(b) = h.state().session.active().and_then(|s| s.doc.layer(id)).and_then(photocraft_engine::layer_multi_cmds::layer_bounds) {
|
||||
let app = h.state_mut();
|
||||
let v = &mut app.ui.views[0];
|
||||
if view == "100" {
|
||||
v.zoom = 1.0;
|
||||
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
|
||||
v.fit_pending = false;
|
||||
} else {
|
||||
v.fit_pending = true;
|
||||
}
|
||||
}
|
||||
settle(h, 6);
|
||||
let Some(from) = point_on(h, id) else { continue };
|
||||
// Press and drag most of the way, render mid-drag, then release and render again.
|
||||
let d = [140.0, 60.0];
|
||||
let p0 = screen(h, from[0], from[1]);
|
||||
h.event(Event::PointerMoved(p0));
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
frame(h);
|
||||
for i in 1..=10 {
|
||||
let f = f64::from(i) / 10.0;
|
||||
h.event(Event::PointerMoved(screen(h, from[0] + d[0] * f, from[1] + d[1] * f)));
|
||||
frame(h);
|
||||
}
|
||||
save(h, &format!("move-{what}-{view}-dragging"));
|
||||
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
|
||||
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
settle(h, 4);
|
||||
save(h, &format!("move-{what}-{view}-released"));
|
||||
let _ = h.state_mut().run("edit.undo", json!({}));
|
||||
settle(h, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--spin`: one interaction over and over for 20 s, for a sampling profiler.
|
||||
fn spin(h: &mut H, what: &str, [pixel, text, shape, group, smart, headline]: [LayerId; 6]) {
|
||||
let t = Instant::now();
|
||||
let mut k = 0usize;
|
||||
if let Some(target) = what.strip_prefix("move-") {
|
||||
let id = match target {
|
||||
"group" => group,
|
||||
"text" => text,
|
||||
"shape" => shape,
|
||||
"smart" => smart,
|
||||
"headline" => headline,
|
||||
_ => pixel,
|
||||
};
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
select(h, id);
|
||||
settle(h, 3);
|
||||
let from = point_on(h, id).unwrap_or([1000.0, 1000.0]);
|
||||
while t.elapsed().as_secs() < 20 {
|
||||
k += 1;
|
||||
let s = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
drag(h, from, [30.0 * s, 20.0 * s], 30);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if what == "click" {
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
let pts: Vec<[f64; 2]> = [text, pixel].iter().filter_map(|&id| point_on(h, id)).collect();
|
||||
while t.elapsed().as_secs() < 20 && !pts.is_empty() {
|
||||
k += 1;
|
||||
let p = screen(h, pts[k % pts.len()][0], pts[k % pts.len()][1]);
|
||||
h.event(Event::PointerMoved(p));
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
settle(h, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
while t.elapsed().as_secs() < 20 {
|
||||
k += 1;
|
||||
match what {
|
||||
"select" => select(h, [pixel, text, shape, group][k % 4]),
|
||||
"tool" => h.key_press([Key::V, Key::B, Key::M, Key::T][k % 4]),
|
||||
_ => {}
|
||||
}
|
||||
frame(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run again with `PHOTOCRAFT_GPU_SYNC=1` unless it is set (the workspace forbids the `unsafe`
|
||||
/// that setting it in-process needs).
|
||||
fn reexec_with_gpu_sync() {
|
||||
if std::env::var_os("PHOTOCRAFT_GPU_SYNC").is_some() {
|
||||
return;
|
||||
}
|
||||
let exe = std::env::current_exe().expect("exe");
|
||||
let status = std::process::Command::new(exe).args(std::env::args().skip(1)).env("PHOTOCRAFT_GPU_SYNC", "1").status().expect("re-exec");
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
@@ -225,6 +225,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
|
||||
if let Some(shown) = crate::adjust_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// Move tool drag: the moving layers at the pointer.
|
||||
if let Some(shown) = crate::move_ui::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
let st = &app.session.documents()[idx];
|
||||
if let Some(l) = live_stroke(app, idx) {
|
||||
return (l.stroke.doc.clone(), l.display_key());
|
||||
@@ -284,21 +288,30 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
if app.gpu.is_none() {
|
||||
return ensure_texture(app, ctx, idx).map(|(t, _)| t);
|
||||
}
|
||||
let (revision, id) = app.session.documents().get(idx).map(|st| (st.revision, st.doc.id))?;
|
||||
// Keyed by the document snapshot, not its revision: selecting a layer changes no pixels.
|
||||
let (snapshot, id) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id))?;
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
let (display, display_key) = canvas_display(app, &doc);
|
||||
let preview_key = preview_key ^ display_key;
|
||||
let key = egui::Id::new(("navigator", id.0));
|
||||
let cached: Option<(u64, u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key));
|
||||
type Cached = (std::sync::Weak<Document>, u64, egui::TextureHandle);
|
||||
let cached: Option<Cached> = ctx.data(|d| d.get_temp(key));
|
||||
if let Some((r, p, t)) = &cached
|
||||
&& (*r, *p) == (revision, preview_key)
|
||||
&& r.ptr_eq(&snapshot)
|
||||
&& *p == preview_key
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While a Move drag is under way the navigator keeps its image and catches up on release.
|
||||
if let Some((_, _, t)) = &cached
|
||||
&& crate::move_ui::showing(app)
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While an adjustment dialog's settings are changing, the navigator keeps its image and
|
||||
// catches up once they settle (a thumbnail composite per change would cost more than the canvas).
|
||||
if let Some((r, _, t)) = &cached
|
||||
&& *r == revision
|
||||
&& r.ptr_eq(&snapshot)
|
||||
&& crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS)
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(NAVIGATOR_SETTLE_MS as u64));
|
||||
@@ -314,7 +327,7 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR),
|
||||
};
|
||||
app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, (revision, preview_key, tex.clone())));
|
||||
ctx.data_mut(|d| d.insert_temp(key, (snapshot, preview_key, tex.clone())));
|
||||
Some(tex.id())
|
||||
}
|
||||
|
||||
@@ -395,11 +408,34 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
// Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick
|
||||
// at the press changes no pixels).
|
||||
if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty())))
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& let Some(r) = crate::move_ui::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
|
||||
{
|
||||
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
|
||||
}
|
||||
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
|
||||
let r = l.since(seen.1 ^ display_key)?;
|
||||
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
|
||||
}
|
||||
|
||||
/// Document `doc`'s canvas caches showed a preview that the edit just committed reproduces
|
||||
/// (`was_preview` tells its keys): count it as the document itself, so the commit's damage rect
|
||||
/// refreshes only that area instead of everything.
|
||||
pub(crate) fn shown_as_document(app: &mut PhotocraftApp, doc: photocraft_doc::DocId, was_preview: impl Fn(u64) -> bool) {
|
||||
let display_key = app.session.active().map_or(0, |st| canvas_display(app, &st.doc).1);
|
||||
if let Some(c) = app.canvases.get_mut(&doc) {
|
||||
if was_preview(c.preview_key ^ display_key) {
|
||||
c.preview_key = display_key;
|
||||
}
|
||||
if was_preview(c.tex_preview_key ^ display_key) {
|
||||
c.tex_preview_key = display_key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
|
||||
/// strokes, …) on the edited layer and on the groups around it reach that far.
|
||||
pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
@@ -1544,7 +1580,8 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
|
||||
painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 3.0, 3.0));
|
||||
}
|
||||
Tool::Move => {
|
||||
// The layers themselves follow the pointer (`move_ui`); the arrow only when they can't.
|
||||
Tool::Move if !crate::move_ui::showing(app) => {
|
||||
let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom);
|
||||
painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent));
|
||||
}
|
||||
@@ -1565,6 +1602,7 @@ fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifi
|
||||
/// Tool state machine. Shared by mouse input and automation.
|
||||
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
let raw = ev;
|
||||
let ev = crate::snap_ui::filter_event(app, ev, mods);
|
||||
if crate::transform_tool::pointer(app, ev, mods) {
|
||||
return;
|
||||
@@ -1719,6 +1757,13 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
let Some(mut d) = app.drag.take() else { return };
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
|
||||
// the release point would otherwise nudge the layer onto a nearby edge.
|
||||
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
|
||||
app.move_preview = None;
|
||||
crate::move_mods::finish(app);
|
||||
return;
|
||||
}
|
||||
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
|
||||
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
|
||||
app.stylus.record_point();
|
||||
@@ -1806,9 +1851,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
Tool::Move => {
|
||||
let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round());
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
let _ = app.run("layer.translate", json!({"dx": dx, "dy": dy}));
|
||||
}
|
||||
crate::move_ui::finish(app, dx, dy);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,8 @@ impl GpuCanvas {
|
||||
let format = self.format_for(doc.depth, size);
|
||||
let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format);
|
||||
let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) };
|
||||
if let Err(e) = comp.supports(doc) {
|
||||
// Checked before allocating a new canvas texture only: `render` plans (and refuses) itself.
|
||||
if fresh && let Err(e) = comp.supports(doc) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -421,6 +422,11 @@ impl GpuCanvas {
|
||||
let damage = damage.filter(|_| self.has(key, size) && self.format_of(key) == Some(format)).map(|r| r.intersect(&bounds));
|
||||
let t0 = now_ms();
|
||||
let mut out = Refresh::default();
|
||||
if damage.is_some_and(|r| r.is_empty()) {
|
||||
// Nothing changed on the canvas (selecting a layer, say): don't even plan.
|
||||
out.kind = "rect";
|
||||
return out;
|
||||
}
|
||||
let region = damage.unwrap_or(bounds);
|
||||
let encode_srgb = display.is_some_and(|d| d.encode_srgb);
|
||||
let gpu = if key == doc.id.0 { self.composite(doc, region, encode_srgb) } else { Err(photocraft_gpu::Unsupported("preview texture".into())) };
|
||||
|
||||
@@ -57,6 +57,7 @@ pub mod menu_catalog;
|
||||
pub mod menu_nav;
|
||||
pub mod menus;
|
||||
pub mod move_mods;
|
||||
pub mod move_ui;
|
||||
pub mod new_doc_ui;
|
||||
pub mod notices;
|
||||
pub mod outline;
|
||||
@@ -213,6 +214,8 @@ pub struct PhotocraftApp {
|
||||
live_stroke: Option<canvas::LiveStroke>,
|
||||
/// Footprint trail of a retouching drag (see `stroke_trail`).
|
||||
trail: Option<stroke_trail::Trail>,
|
||||
/// Move tool drag shown live (`move_ui`).
|
||||
pub(crate) move_preview: Option<move_ui::MovePreview>,
|
||||
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
|
||||
secondary_erase: bool,
|
||||
/// End of the last painting stroke: ⇧-click draws a straight line from it (#178).
|
||||
@@ -325,6 +328,7 @@ impl PhotocraftApp {
|
||||
drag: None,
|
||||
live_stroke: None,
|
||||
trail: None,
|
||||
move_preview: None,
|
||||
secondary_erase: false,
|
||||
last_stroke_end: None,
|
||||
control_rx: None,
|
||||
|
||||
@@ -696,7 +696,10 @@ fn menu_tint(name: &str) -> Option<egui::Color32> {
|
||||
|
||||
/// Draws the menu bar; returns the right edge of the last menu title (the bar itself fills the row).
|
||||
pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
let items = menu_items(app);
|
||||
// Built only while a menu is open: every item's enabled/checked state scales with the
|
||||
// document (layer lookups), which cost milliseconds per frame on large layouts (#125).
|
||||
let items: std::cell::OnceCell<Vec<MenuItem>> = std::cell::OnceCell::new();
|
||||
let app_ref: &PhotocraftApp = app;
|
||||
let mut right = ui.cursor().left();
|
||||
let mut clicked: Option<String> = None;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
@@ -713,8 +716,9 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
nav.bar_bottom = Some(ui.max_rect().bottom());
|
||||
let mut buttons = Vec::with_capacity(TOP_MENUS.len());
|
||||
for top in TOP_MENUS {
|
||||
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
let r = ui.menu_button(egui::RichText::new(top).color(t.text_dim), |ui| {
|
||||
let items = items.get_or_init(|| menu_items(app_ref));
|
||||
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
ui.set_min_width(220.0);
|
||||
if mine.is_empty() {
|
||||
ui.weak("(coming soon)");
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Move tool drags shown live (#128): while the pointer drags, the canvas shows the document with
|
||||
//! the moving layers already at the pointer, exactly as `layer.translate` will leave them, and
|
||||
//! recomposites only where they were and where they are. Releasing commits one `layer.translate`
|
||||
//! (one history step) whose damage rect refreshes the same area.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{DocId, Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Preview keys of move drags: `BASE + n`, one per offset shown (see `canvas::display_doc`).
|
||||
const BASE: u64 = 1 << 39;
|
||||
|
||||
pub(crate) struct MovePreview {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
/// Layers that move ([`photocraft_engine::layer_multi_cmds::move_targets`]).
|
||||
ids: Vec<LayerId>,
|
||||
/// What moving them can change at offset (0, 0) (`None` = anything).
|
||||
bounds: Option<Rect>,
|
||||
/// Offsets shown so far, by preview key (`BASE + index`).
|
||||
offsets: Vec<(i32, i32)>,
|
||||
/// The document at the latest offset.
|
||||
shown: Option<Arc<Document>>,
|
||||
}
|
||||
|
||||
impl MovePreview {
|
||||
fn key(&self) -> u64 {
|
||||
BASE + self.offsets.len() as u64
|
||||
}
|
||||
fn offset_of(&self, key: u64) -> Option<(i32, i32)> {
|
||||
if key == 0 {
|
||||
return Some((0, 0));
|
||||
}
|
||||
let i = usize::try_from(key.checked_sub(BASE + 1)?).ok()?;
|
||||
self.offsets.get(i).copied()
|
||||
}
|
||||
fn area(&self, d: (i32, i32)) -> Option<Rect> {
|
||||
let b = self.bounds?;
|
||||
Some(if b.is_empty() { b } else { b.translate(d.0, d.1).inflate(1) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole-pixel offset of the current Move drag on document `idx`, if one is under way.
|
||||
fn drag_offset(app: &PhotocraftApp) -> Option<(i32, i32)> {
|
||||
let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move)?;
|
||||
let end = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let dx = (end[0] - d.start[0]).round().clamp(-1e7, 1e7) as i32;
|
||||
let dy = (end[1] - d.start[1]).round().clamp(-1e7, 1e7) as i32;
|
||||
Some((dx, dy))
|
||||
}
|
||||
|
||||
/// The document to show while a Move drag is under way on document `idx`: the moving layers at
|
||||
/// the pointer. `None` without a drag (or when the layers can't move: locked, say; the drag then
|
||||
/// shows its arrow and the release reports why).
|
||||
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
|
||||
if app.session.active_index() != Some(idx) {
|
||||
return None;
|
||||
}
|
||||
let Some(offset) = drag_offset(app) else {
|
||||
app.move_preview = None;
|
||||
return None;
|
||||
};
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let (doc_id, revision, doc) = (st.doc.id, st.revision, st.doc.clone());
|
||||
let fresh = app.move_preview.as_ref().is_some_and(|p| p.doc == doc_id && p.revision == revision);
|
||||
if !fresh {
|
||||
let ids = photocraft_engine::layer_multi_cmds::move_targets(&doc, &st.selected_layers());
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bounds = photocraft_engine::layer_multi_cmds::layers_damage(&doc, &doc, &ids);
|
||||
app.move_preview = Some(MovePreview { doc: doc_id, revision, ids, bounds, offsets: Vec::new(), shown: None });
|
||||
}
|
||||
let p = app.move_preview.as_mut()?;
|
||||
if offset == (0, 0) && p.offsets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if p.offsets.last() != Some(&offset) {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
match photocraft_engine::layer_multi_cmds::moved(&doc, &p.ids, offset.0, offset.1) {
|
||||
Ok(d) => {
|
||||
// Duotone documents display through their inks.
|
||||
let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d);
|
||||
p.shown = Some(Arc::new(d));
|
||||
p.offsets.push(offset);
|
||||
}
|
||||
Err(_) => {
|
||||
p.shown = None;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
app.perf.span("move preview", crate::gpu_canvas::now_ms() - t0);
|
||||
}
|
||||
let p = app.move_preview.as_ref()?;
|
||||
Some((p.shown.clone()?, p.key()))
|
||||
}
|
||||
|
||||
/// What changed between preview (or document) key `seen` and `now` of the current Move drag on
|
||||
/// document `doc` at `revision`: where the moving layers were in both. `None` when either key
|
||||
/// isn't one of this drag's (the canvas then recomposites everything).
|
||||
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
|
||||
let p = app.move_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
|
||||
if seen == now || (seen < BASE && seen != 0) || (now < BASE && now != 0) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (p.area(p.offset_of(seen)?)?, p.area(p.offset_of(now)?)?);
|
||||
Some(if a.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
a
|
||||
} else {
|
||||
a.union(&b)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the canvas shows a Move drag's layers at the pointer.
|
||||
pub(crate) fn showing(app: &PhotocraftApp) -> bool {
|
||||
app.move_preview.as_ref().is_some_and(|p| p.shown.is_some())
|
||||
}
|
||||
|
||||
/// Whether `key` is a Move drag preview key.
|
||||
pub(crate) fn is_preview_key(key: u64) -> bool {
|
||||
key > BASE && key < BASE << 1
|
||||
}
|
||||
|
||||
/// Release: one `layer.translate` by the drag's offset. The canvas already shows the result, so
|
||||
/// its caches are told it showed the document: the command's damage rect refreshes that area.
|
||||
pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) {
|
||||
// Only when the canvas shows this very offset (else it recomposites everything once).
|
||||
let at = (dx.clamp(-1e7, 1e7) as i32, dy.clamp(-1e7, 1e7) as i32);
|
||||
let shown = app.move_preview.take().filter(|p| p.shown.is_some() && p.offsets.last() == Some(&at));
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return;
|
||||
}
|
||||
if app.run("layer.translate", serde_json::json!({"dx": dx, "dy": dy})).is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = shown else { return };
|
||||
crate::canvas::shown_as_document(app, p.doc, is_preview_key);
|
||||
}
|
||||
@@ -1389,6 +1389,16 @@ fn layer_row(
|
||||
layer_drag_and_drop(ctx, ui, l, rect, &resp, actions);
|
||||
// Rows are painted: name them for screen readers and UI tests.
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name));
|
||||
// A row scrolled out of view only keeps its place (#125): a layout's hundreds of rows would
|
||||
// otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their
|
||||
// disclosure triangle is a widget (accessibility, scroll-to).
|
||||
if !ui.is_rect_visible(rect)
|
||||
&& !l.is_group()
|
||||
&& !resp.context_menu_opened()
|
||||
&& ctx.data(|d| d.get_temp::<String>(egui::Id::new(("rename", l.id.0)))).is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let painter = ui.painter_at(rect.expand(1.0));
|
||||
if t.pro {
|
||||
if selected {
|
||||
@@ -1684,11 +1694,12 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(id) = st.active_layer else { return };
|
||||
let Some(layer) = st.doc.layer(id).cloned() else { return };
|
||||
let Some(layer) = st.doc.layer(id) else { return };
|
||||
// The floating card appears for adjustment and fill layers (their controls live here).
|
||||
if !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) {
|
||||
return;
|
||||
}
|
||||
let layer = layer.clone();
|
||||
let t = Tokens::get(ctx);
|
||||
let canvas = app.last_canvas_rect;
|
||||
let width = 320.0;
|
||||
@@ -1821,9 +1832,11 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = st.active_layer else { return };
|
||||
let Some(layer) = st.doc.layer(id).cloned() else { return };
|
||||
// Borrowed from the document snapshot: cloning the layer every frame copied whole groups.
|
||||
let doc = st.doc.clone();
|
||||
let Some(layer) = doc.layer(id) else { return };
|
||||
// Header: kind icon, layer name and kind (#155); sections below draw their own separators.
|
||||
crate::props_layout::header(ui, &layer);
|
||||
crate::props_layout::header(ui, layer);
|
||||
let is_adjustment = matches!(layer.content, LayerContent::Adjustment(_));
|
||||
if is_adjustment || layer.artboard().is_some() || !t.pro {
|
||||
ui.add_space(4.0);
|
||||
@@ -1833,12 +1846,12 @@ fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
adjustment_controls(app, ui, id, adj);
|
||||
} else if layer.artboard().is_some() {
|
||||
crate::artboard_ui::properties(app, ui, &layer);
|
||||
crate::artboard_ui::properties(app, ui, layer);
|
||||
} else if t.pro {
|
||||
// Transform, Align, the kind's sections and Quick Actions.
|
||||
crate::layer_props_ui::properties(app, ui, &layer);
|
||||
crate::layer_props_ui::properties(app, ui, layer);
|
||||
} else {
|
||||
layer_controls(app, ui, &layer);
|
||||
layer_controls(app, ui, layer);
|
||||
if matches!(layer.content, LayerContent::Text(_)) {
|
||||
crate::type_tool::type_properties(app, ui);
|
||||
}
|
||||
@@ -2155,6 +2168,9 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
|
||||
}
|
||||
for (i, (name, on, kind)) in rows.into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if !ui.is_rect_visible(rect) {
|
||||
continue;
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35));
|
||||
}
|
||||
|
||||
@@ -127,12 +127,14 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace)
|
||||
let layer = match source {
|
||||
HistSource::BelowLayer(l) | HistSource::Layer(l) => l,
|
||||
};
|
||||
if let Some((d, l, r, h)) = &app.tone_hist
|
||||
let pixels_kept = view_only(st);
|
||||
if let Some((d, l, r, h)) = app.tone_hist.as_mut()
|
||||
&& *d == doc_id
|
||||
&& *l == layer
|
||||
&& *r == rev
|
||||
&& (*r == rev || (*r + 1 == rev && pixels_kept))
|
||||
&& h.tag == want
|
||||
{
|
||||
*r = rev;
|
||||
return h.clone();
|
||||
}
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
@@ -142,6 +144,12 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace)
|
||||
h
|
||||
}
|
||||
|
||||
/// Whether the latest revision changed no pixels (selecting a layer, say): cached histograms of
|
||||
/// the previous revision still hold (#125).
|
||||
fn view_only(st: &photocraft_engine::DocState) -> bool {
|
||||
st.last_damage.is_some_and(|r| r.is_empty())
|
||||
}
|
||||
|
||||
/// After an editor commits its own adjustment layer, the image below it is unchanged: keep the
|
||||
/// cached histogram valid for the new revision. Call with the revision seen before the commit.
|
||||
pub fn keep_after_commit(app: &mut PhotocraftApp, layer: LayerId, before: Option<u64>) {
|
||||
@@ -220,6 +228,13 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let (doc_id, rev) = (st.doc.id, st.revision);
|
||||
let now = crate::gpu_canvas::now_ms();
|
||||
if view_only(st)
|
||||
&& let Some((d, r, _, _)) = app.doc_hist.as_mut()
|
||||
&& *d == doc_id
|
||||
&& *r + 1 == rev
|
||||
{
|
||||
*r = rev;
|
||||
}
|
||||
let stale = !matches!(&app.doc_hist, Some((d, r, _, _)) if *d == doc_id && *r == rev);
|
||||
let due = app.doc_hist.as_ref().is_none_or(|(d, _, at, _)| *d != doc_id || now - at > 250.0);
|
||||
if stale && due {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Layout documents (#125, #128) through the real app: the Move tool shows the layers at the
|
||||
//! pointer while dragging and commits one `layer.translate` (one undo step) that lands where the
|
||||
//! pointer went; Auto-Select takes the layer under the pointer; selecting a layer recomposites
|
||||
//! nothing and hiding one recomposites only its area; and the GPU compositor (which composites
|
||||
//! small layers over their bounds only) matches the CPU reference on the whole document.
|
||||
|
||||
#[path = "support/layout_doc.rs"]
|
||||
mod layout_doc;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_engine::layer_multi_cmds::{layer_bounds, move_targets, moved};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
use serde_json::json;
|
||||
|
||||
type H = Harness<'static, PhotocraftApp>;
|
||||
|
||||
/// The app (CPU canvas) with the small layout document open.
|
||||
fn app() -> (H, layout_doc::Handles) {
|
||||
let (doc, handles) = layout_doc::build(layout_doc::Spec::small());
|
||||
let h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
|
||||
app.session.open_document(doc, None);
|
||||
app.sync_views();
|
||||
app
|
||||
});
|
||||
let mut h = h;
|
||||
h.run_steps(6);
|
||||
(h, handles)
|
||||
}
|
||||
|
||||
fn doc(h: &H) -> std::sync::Arc<Document> {
|
||||
h.state().session.active().expect("document").doc.clone()
|
||||
}
|
||||
|
||||
fn bounds(h: &H, id: LayerId) -> Rect {
|
||||
layer_bounds(doc(h).layer(id).expect("layer")).expect("bounds")
|
||||
}
|
||||
|
||||
fn screen(h: &H, p: [f64; 2]) -> Pos2 {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect);
|
||||
ViewXform { rect, zoom: v.zoom, center: v.center, flip: false }.to_screen(p[0] as f32, p[1] as f32)
|
||||
}
|
||||
|
||||
/// A point where `id` (or, for a group, one of its layers) is the topmost layer with pixels.
|
||||
fn point_on(h: &H, id: LayerId) -> [f64; 2] {
|
||||
fn holds(l: &photocraft_doc::Layer, id: LayerId) -> bool {
|
||||
l.id == id || l.children().is_some_and(|c| c.iter().any(|c| holds(c, id)))
|
||||
}
|
||||
let d = doc(h);
|
||||
let b = bounds(h, id);
|
||||
let layer = d.layer(id).expect("layer");
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
if photocraft_engine::pick_cmds::layers_at(&d, x, y).first().is_some_and(|&top| holds(layer, top)) {
|
||||
return [x as f64 + 0.5, y as f64 + 0.5];
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("layer {id:?} has no visible pixel");
|
||||
}
|
||||
|
||||
/// Press at document point `from`, move by `d` in `steps` frames; returns before releasing.
|
||||
fn press_and_drag(h: &mut H, from: [f64; 2], d: [f64; 2], steps: usize) {
|
||||
let p0 = screen(h, from);
|
||||
h.event(Event::PointerMoved(p0));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for i in 1..=steps {
|
||||
let f = i as f64 / steps as f64;
|
||||
h.event(Event::PointerMoved(screen(h, [from[0] + d[0] * f, from[1] + d[1] * f])));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn release(h: &mut H, at: [f64; 2]) {
|
||||
let p = screen(h, at);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Zoom so one document pixel is one screen point (pointer deltas are then exact).
|
||||
fn actual_pixels(h: &mut H) {
|
||||
let app = h.state_mut();
|
||||
let size = app.session.active().map(|s| s.doc.size).expect("doc");
|
||||
app.ui.views[0].zoom = 1.0;
|
||||
app.ui.views[0].center = [size.width as f32 / 2.0, size.height as f32 / 2.0];
|
||||
app.ui.views[0].fit_pending = false;
|
||||
// View › Snap and smart guides would pull the layers onto their neighbours' edges.
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_drag_follows_the_pointer_and_is_one_undo_step() {
|
||||
let (mut h, k) = app();
|
||||
actual_pixels(&mut h);
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = false;
|
||||
for id in [k.text, k.shape, k.group, k.smart, k.pixel] {
|
||||
h.state_mut().run("layer.select", json!({"layer": id.0})).expect("select");
|
||||
h.run_steps(2);
|
||||
let before = bounds(&h, id);
|
||||
let undo = h.state().session.active().expect("doc").history.entries().len();
|
||||
let from = point_on(&h, id);
|
||||
press_and_drag(&mut h, from, [40.0, 25.0], 6);
|
||||
// While dragging the document is untouched (no history step per pointer move) and the
|
||||
// canvas shows the moved layers.
|
||||
assert_eq!(bounds(&h, id), before, "the drag commits nothing until release");
|
||||
assert_eq!(h.state().session.active().expect("doc").history.entries().len(), undo);
|
||||
assert!(h.state().perf.spans.contains_key("move preview"), "the canvas shows the layers moving");
|
||||
release(&mut h, [from[0] + 40.0, from[1] + 25.0]);
|
||||
assert_eq!(bounds(&h, id), before.translate(40, 25), "the layer lands where the pointer went");
|
||||
assert_eq!(h.state().session.active().expect("doc").history.entries().len(), undo + 1, "one undo step");
|
||||
h.state_mut().run("edit.undo", json!({})).expect("undo");
|
||||
assert_eq!(bounds(&h, id), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_select_moves_the_layer_under_the_pointer() {
|
||||
let (mut h, k) = app();
|
||||
actual_pixels(&mut h);
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
h.state_mut().run("layer.select", json!({"layer": k.pixel.0})).expect("select");
|
||||
h.run_steps(2);
|
||||
let (pixel_before, text_before) = (bounds(&h, k.pixel), bounds(&h, k.text));
|
||||
let from = point_on(&h, k.text);
|
||||
press_and_drag(&mut h, from, [-15.0, 10.0], 4);
|
||||
release(&mut h, [from[0] - 15.0, from[1] + 10.0]);
|
||||
assert_eq!(h.state().session.active().and_then(|s| s.active_layer), Some(k.text));
|
||||
assert_eq!(bounds(&h, k.text), text_before.translate(-15, 10));
|
||||
assert_eq!(bounds(&h, k.pixel), pixel_before, "the previously selected layer stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_tool_click_selects_without_moving() {
|
||||
// Snapping stays on (the default): it used to pull the release point onto a nearby edge, so
|
||||
// a plain click nudged the layer (and recomposited the whole document).
|
||||
let (mut h, k) = app();
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
for id in [k.text, k.shape, k.pixel, k.text] {
|
||||
let before = (bounds(&h, id), h.state().session.active().expect("doc").history.entries().len());
|
||||
let p = screen(&h, point_on(&h, id));
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().and_then(|s| s.active_layer), Some(id), "the click selects the layer under it");
|
||||
assert_eq!((bounds(&h, id), h.state().session.active().expect("doc").history.entries().len()), before, "and moves nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_recomposites_nothing_and_hiding_only_the_layer() {
|
||||
let (mut h, k) = app();
|
||||
for id in [k.text, k.group, k.smart, k.shape] {
|
||||
h.state_mut().perf.last_refresh = "untouched";
|
||||
h.state_mut().run("layer.select", json!({"layer": id.0})).expect("select");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().perf.last_refresh, "untouched", "selecting a layer must not recomposite");
|
||||
}
|
||||
// The Properties panel's Levels histogram isn't recomputed for a selection change either.
|
||||
let levels = doc(&h).layers.iter().rev().find(|l| matches!(l.content, LayerContent::Adjustment(_))).map(|l| l.id).expect("levels layer");
|
||||
h.state_mut().run("layer.select", json!({"layer": levels.0})).expect("select");
|
||||
h.run_steps(3);
|
||||
assert!(h.state().perf.spans.contains_key("histogram"), "the Levels editor shows a histogram");
|
||||
h.state_mut().perf.spans.remove("histogram");
|
||||
h.state_mut().run("layer.select", json!({"layer": levels.0})).expect("select");
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().perf.spans.contains_key("histogram"), "selecting changed no pixels");
|
||||
// Hiding a text layer refreshes its area, not the whole canvas.
|
||||
h.state_mut().run("layer.setProps", json!({"layer": k.text.0, "visible": false})).expect("hide");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().perf.last_refresh, "rect");
|
||||
let d = doc(&h);
|
||||
let reduced = h.state().perf.last_refresh_px;
|
||||
assert!(reduced < d.size.area() / 4, "{reduced} px refreshed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_move_preview_is_what_the_move_commits() {
|
||||
let (d, k) = layout_doc::build(layout_doc::Spec::small());
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.open_document(d, None);
|
||||
for id in [k.text, k.shape, k.group, k.smart, k.pixel, k.closed_group] {
|
||||
s.execute("layer.select", json!({"layer": id.0})).expect("select");
|
||||
let before = s.active().expect("doc").doc.clone();
|
||||
let ids = move_targets(&before, &[id]);
|
||||
let preview = moved(&before, &ids, 13, -7).expect("preview");
|
||||
s.execute("layer.translate", json!({"dx": 13, "dy": -7})).expect("move");
|
||||
let after = s.active().expect("doc");
|
||||
// The damage the canvas refreshes covers where the layer was and is.
|
||||
let dmg = after.last_damage.unwrap_or_else(|| panic!("{id:?} {}: a move has a damage rect", before.layer(id).map_or("?", |l| l.content.kind_name())));
|
||||
let b0 = photocraft_compose::composite_bounds(before.layer(id).expect("layer"), before.bounds()).expect("bounds");
|
||||
assert!(dmg.contains_rect(&b0) && dmg.contains_rect(&b0.translate(13, -7).intersect(&before.bounds())), "{dmg:?} vs {b0:?}");
|
||||
let (a, b) = (photocraft_compose::flatten(&preview), photocraft_compose::flatten(&after.doc));
|
||||
let worst = a.px.iter().zip(&b.px).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 2.0 / 255.0, "{id:?}: preview differs from the commit by {}/255", worst * 255.0);
|
||||
s.execute("edit.undo", json!({})).expect("undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_document_composites_on_the_gpu_like_the_cpu() {
|
||||
use eframe::wgpu;
|
||||
fn block_on<F: std::future::Future>(f: F) -> F::Output {
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut f = std::pin::pin!(f);
|
||||
loop {
|
||||
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
|
||||
return v;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
if photocraft_gpu::Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
|
||||
eprintln!("skipping: adapter can't render Rgba32Float");
|
||||
return;
|
||||
}
|
||||
let Ok((device, queue)) = block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { return };
|
||||
let Ok(mut comp) = photocraft_gpu::Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) else { return };
|
||||
let (d, _) = layout_doc::build(layout_doc::Spec::small());
|
||||
let (_, texts) = layout_doc::count(&d);
|
||||
assert!(texts >= 10);
|
||||
assert!(d.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_))));
|
||||
let cpu = photocraft_compose::flatten(&d);
|
||||
let gpu = photocraft_gpu::render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).expect("the layout composites on the GPU");
|
||||
let mut worst = 0.0f32;
|
||||
for (c, g) in cpu.px.iter().zip(&gpu) {
|
||||
for k in 0..4 {
|
||||
let (a, b) = if k == 3 { (c[3], g[3]) } else { (c[k] * c[3], g[k] * g[3]) };
|
||||
worst = worst.max((a - b).abs());
|
||||
}
|
||||
}
|
||||
assert!(worst <= 2.0 / 255.0, "GPU differs from the CPU by {}/255", worst * 255.0);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! A synthetic "designer's layout" document (#125, #128): a web/landing-page style PSD with
|
||||
//! nested groups (pass-through and isolated), dozens of text layers in several fonts and sizes,
|
||||
//! shape layers with strokes, smart objects, layer effects (drop shadow, stroke, gradient overlay),
|
||||
//! adjustment layers and layer masks. Shared by `examples/layout_bench.rs` and the
|
||||
//! `layout_perf` tests (`#[path]`-included; deterministic, no files).
|
||||
//!
|
||||
//! [`Spec::full`] is a 4000×3000 document with 174 layers (60 of them type); the tests use a small
|
||||
//! one (`cards` sets how many product cards, the bulk of the layer count).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Effect, FxCommon, FxPaint, Gradient, Layer, LayerContent, LayerId, LayerMask, PixelFormat, StrokeFx, StrokePosition};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Size and content knobs.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Spec {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Product cards (7 layers each: group, card, image smart object, title, body, price, badge).
|
||||
pub cards: usize,
|
||||
/// Gallery tiles (masked pixel layers).
|
||||
pub tiles: usize,
|
||||
}
|
||||
|
||||
impl Spec {
|
||||
/// The benchmark document: 4000×3000, 174 layers, 60 text layers.
|
||||
pub fn full() -> Self {
|
||||
Spec { width: 4000, height: 3000, cards: 16, tiles: 24 }
|
||||
}
|
||||
/// A quick one for tests: same structure, fewer and smaller layers.
|
||||
pub fn small() -> Self {
|
||||
Spec { width: 800, height: 600, cards: 3, tiles: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles to representative layers (for driving the UI).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Handles {
|
||||
pub pixel: LayerId,
|
||||
pub text: LayerId,
|
||||
pub shape: LayerId,
|
||||
pub group: LayerId,
|
||||
pub smart: LayerId,
|
||||
/// A closed group (collapsed in the Layers panel).
|
||||
pub closed_group: LayerId,
|
||||
}
|
||||
|
||||
fn exec(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> LayerId {
|
||||
LayerId(v.get("layer").or_else(|| v.get("id")).and_then(Value::as_u64).expect("layer id in result"))
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> LayerId {
|
||||
s.active().and_then(|st| st.active_layer).expect("active layer")
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random in [0, 1).
|
||||
fn rnd(k: u64) -> f32 {
|
||||
let mut x = k.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03;
|
||||
x ^= x >> 31;
|
||||
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x ^= x >> 29;
|
||||
(x >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
fn hex(c: [f32; 3]) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)
|
||||
}
|
||||
|
||||
fn drop_shadow(distance: f32, size: f32) -> Effect {
|
||||
let Effect::DropShadow(mut ds) = Effect::default_drop_shadow() else { unreachable!("default drop shadow") };
|
||||
ds.distance = distance;
|
||||
ds.size = size;
|
||||
ds.common.opacity = 0.35;
|
||||
Effect::DropShadow(ds)
|
||||
}
|
||||
|
||||
fn stroke_fx(size: f32, c: [f32; 3]) -> Effect {
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(c[0], c[1], c[2])),
|
||||
})
|
||||
}
|
||||
|
||||
fn gradient_overlay(a: [f32; 3], b: [f32; 3]) -> Effect {
|
||||
Effect::GradientOverlay {
|
||||
common: FxCommon::new(BlendMode::Normal, 0.6),
|
||||
gradient: Gradient { stops: vec![(0.0, Color::rgb(a[0], a[1], a[2])), (1.0, Color::rgb(b[0], b[1], b[2]))], angle: 90.0, ..Default::default() },
|
||||
dither: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo-ish pixel block (gradient + soft noise), `r` in document coordinates.
|
||||
fn photo_layer(name: &str, fmt: PixelFormat, r: Rect, seed: u64) -> Layer {
|
||||
let mut l = Layer::raster(name, fmt);
|
||||
let s = l.surface_mut().expect("raster");
|
||||
let step = 16;
|
||||
let base = [rnd(seed), rnd(seed + 1), rnd(seed + 2)];
|
||||
let mut y = r.y0;
|
||||
while y < r.y1 {
|
||||
let mut x = r.x0;
|
||||
while x < r.x1 {
|
||||
let fx = (x - r.x0) as f32 / r.width().max(1) as f32;
|
||||
let fy = (y - r.y0) as f32 / r.height().max(1) as f32;
|
||||
let n = rnd(seed ^ ((x as u64) << 20) ^ y as u64) * 0.08;
|
||||
let px = [(base[0] * 0.7 + 0.3 * fx + n).min(1.0), (base[1] * 0.6 + 0.4 * fy + n).min(1.0), (base[2] * 0.8 + 0.2 * (1.0 - fx) + n).min(1.0), 1.0];
|
||||
s.fill_rect(Rect::new(x, y, (x + step).min(r.x1), (y + step).min(r.y1)), &px);
|
||||
x += step;
|
||||
}
|
||||
y += step;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn take(doc: &mut Document, id: LayerId) -> Layer {
|
||||
doc.remove(id).unwrap_or_else(|| panic!("layer {id:?} missing"))
|
||||
}
|
||||
|
||||
/// Builds the layout document (and the session that built it, for its text engine state).
|
||||
pub fn build(spec: Spec) -> (Document, Handles) {
|
||||
let (w, h) = (spec.width as i32, spec.height as i32);
|
||||
let u = w as f32 / 4000.0; // layout unit: 1 at 4000 px wide
|
||||
let px = |v: f32| (v * u).round() as i32;
|
||||
let pt = |v: f32| (v * u).max(4.0);
|
||||
let doc = Document::with_background("Layout", Size::new(spec.width, spec.height), ColorMode::Rgb, SampleType::U8, Color::rgb(0.97, 0.97, 0.96));
|
||||
let fmt = doc.pixel_format();
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
let fonts = ["Inter", "JetBrains Mono", "Helvetica", "Georgia", "Arial"];
|
||||
let text = |s: &mut Session, x: i32, y: i32, t: &str, font: &str, size: f32, color: [f32; 3], extra: Value| -> LayerId {
|
||||
let mut p = json!({"x": x, "y": y, "text": t, "font": font, "size": size, "color": hex(color)});
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
id_of(&exec(s, "type.create", p))
|
||||
};
|
||||
let shape = |s: &mut Session, kind: &str, r: [i32; 4], fill: [f32; 3], stroke: Option<(f32, [f32; 3])>, name: &str| -> LayerId {
|
||||
let mut p = json!({"kind": kind, "rect": r, "fill": hex(fill), "name": name, "radii": px(24.0)});
|
||||
if let Some((wd, c)) = stroke {
|
||||
p["stroke"] = json!({"width": wd, "color": hex(c), "align": "inside"});
|
||||
}
|
||||
exec(s, "shape.create", p);
|
||||
active(s)
|
||||
};
|
||||
let smart = |s: &mut Session, name: &str, r: Rect, seed: u64| -> LayerId {
|
||||
let st = s.active_mut().expect("doc");
|
||||
let mut doc = (*st.doc).clone();
|
||||
let l = photo_layer(name, fmt, r, seed);
|
||||
let id = doc.insert_above(None, l);
|
||||
st.doc = std::sync::Arc::new(doc);
|
||||
exec(s, "layer.select", json!({"layer": id.0}));
|
||||
id_of(&exec(s, "layer.smartObjects.convertToSmartObject", json!({"layer": id.0})))
|
||||
};
|
||||
let ink = [0.12, 0.13, 0.16];
|
||||
let accent = [0.85, 0.32, 0.18];
|
||||
|
||||
// ---- header -------------------------------------------------------------------------------
|
||||
let logo = smart(&mut s, "Logo", Rect::new(px(120.0), px(60.0), px(420.0), px(220.0)), 1);
|
||||
let nav: Vec<LayerId> = ["Home", "Products", "Pricing", "Journal", "About", "Contact"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| text(&mut s, px(1700.0 + i as f32 * 340.0), px(160.0), t, fonts[i % 2], pt(28.0), ink, json!({})))
|
||||
.collect();
|
||||
let rule = shape(&mut s, "rect", [0, px(260.0), w, px(4.0).max(1)], [0.85, 0.85, 0.85], None, "Rule");
|
||||
|
||||
// ---- hero ---------------------------------------------------------------------------------
|
||||
let hero_img = photo_layer("Hero photo", fmt, Rect::new(0, px(270.0), w, px(1500.0)), 7);
|
||||
let hero_img_id = {
|
||||
let st = s.active_mut().expect("doc");
|
||||
let mut doc = (*st.doc).clone();
|
||||
let mut l = hero_img;
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(0, px(1300.0), w, px(1500.0)), &[0.4]);
|
||||
l.mask = Some(m);
|
||||
l.effects.items = vec![gradient_overlay([0.1, 0.1, 0.3], [0.9, 0.5, 0.2])];
|
||||
let id = doc.insert_above(None, l);
|
||||
st.doc = std::sync::Arc::new(doc);
|
||||
id
|
||||
};
|
||||
let headline = text(&mut s, px(240.0), px(800.0), "Make things that last", fonts[3], pt(160.0), [1.0, 1.0, 1.0], json!({}));
|
||||
let subhead = text(
|
||||
&mut s,
|
||||
0,
|
||||
0,
|
||||
"Hand-finished goods for everyday use. Designed in small batches, built to be repaired, and shipped carbon-neutral.",
|
||||
fonts[0],
|
||||
pt(40.0),
|
||||
[0.95, 0.95, 0.95],
|
||||
json!({"box": [px(240.0), px(900.0), px(1800.0), px(260.0)]}),
|
||||
);
|
||||
let cta_bg = shape(&mut s, "roundedRect", [px(240.0), px(1200.0), px(520.0), px(130.0)], accent, Some((pt(6.0), [1.0, 1.0, 1.0])), "Button");
|
||||
let cta_txt = text(&mut s, px(300.0), px(1285.0), "Shop the collection", fonts[0], pt(36.0), [1.0, 1.0, 1.0], json!({"weight": 700}));
|
||||
let hue = id_of(&exec(&mut s, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -15})));
|
||||
|
||||
// ---- cards --------------------------------------------------------------------------------
|
||||
let cols = 4usize;
|
||||
let (cw, ch) = (px(860.0), px(1150.0));
|
||||
let mut cards: Vec<Vec<LayerId>> = Vec::new();
|
||||
for i in 0..spec.cards {
|
||||
let (cx, cy) = (px(160.0) + (i % cols) as i32 * px(940.0), px(1600.0) + (i / cols) as i32 * (ch / 4));
|
||||
let bg = shape(&mut s, "roundedRect", [cx, cy, cw, ch / 5], [1.0, 1.0, 1.0], Some((pt(3.0), [0.8, 0.8, 0.82])), "Card");
|
||||
let img = smart(&mut s, &format!("Product {}", i + 1), Rect::new(cx + px(20.0), cy + px(20.0), cx + cw - px(20.0), cy + ch / 9), 100 + i as u64);
|
||||
let title = text(&mut s, cx + px(30.0), cy + ch / 9 + px(50.0), &format!("Product {} — Oak", i + 1), fonts[i % fonts.len()], pt(34.0), ink, json!({}));
|
||||
let body = text(
|
||||
&mut s,
|
||||
0,
|
||||
0,
|
||||
"Solid oak, oiled by hand. Lifetime repairs.",
|
||||
fonts[(i + 1) % fonts.len()],
|
||||
pt(20.0),
|
||||
[0.4, 0.4, 0.42],
|
||||
json!({"box": [cx + px(30.0), cy + ch / 9 + px(70.0), cw - px(60.0), px(60.0)]}),
|
||||
);
|
||||
let price = text(&mut s, cx + cw - px(200.0), cy + ch / 9 + px(50.0), &format!("${}", 40 + i * 15), fonts[1], pt(30.0), accent, json!({}));
|
||||
let badge = shape(&mut s, "star", [cx + cw - px(120.0), cy + px(40.0), px(80.0), px(80.0)], accent, Some((pt(2.0), [1.0, 1.0, 1.0])), "Badge");
|
||||
cards.push(vec![bg, img, title, body, price, badge]);
|
||||
}
|
||||
|
||||
// ---- gallery ------------------------------------------------------------------------------
|
||||
let mut tiles = Vec::new();
|
||||
for i in 0..spec.tiles {
|
||||
let (tx, ty) = (px(160.0) + (i % 8) as i32 * px(470.0), px(2350.0) + (i / 8) as i32 * px(200.0));
|
||||
tiles.push(photo_layer(&format!("Tile {}", i + 1), fmt, Rect::new(tx, ty, tx + px(440.0), ty + px(180.0)), 500 + i as u64));
|
||||
}
|
||||
let gallery_title = text(&mut s, px(160.0), px(2300.0), "From the workshop", fonts[3], pt(64.0), ink, json!({}));
|
||||
let curves = id_of(&exec(&mut s, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [100, 120], [255, 255]]})));
|
||||
|
||||
// ---- footer -------------------------------------------------------------------------------
|
||||
let foot_bg = shape(&mut s, "rect", [0, px(2800.0), w, px(200.0)], [0.12, 0.13, 0.16], None, "Footer bg");
|
||||
let mut foot = Vec::new();
|
||||
for i in 0..2 {
|
||||
foot.push(text(
|
||||
&mut s,
|
||||
px(160.0) + (i % 5) as i32 * px(700.0),
|
||||
px(2870.0) + (i / 5) as i32 * px(60.0),
|
||||
["Shipping", "Returns", "Repairs", "Wholesale", "Press", "Careers", "Privacy", "Terms", "Instagram", "Newsletter"][i],
|
||||
fonts[i % 3],
|
||||
pt(22.0),
|
||||
[0.85, 0.85, 0.86],
|
||||
json!({}),
|
||||
));
|
||||
}
|
||||
let foot_icons: Vec<LayerId> =
|
||||
(0..5).map(|i| shape(&mut s, "ellipse", [w - px(600.0) + i * px(100.0), px(2870.0), px(60.0), px(60.0)], [0.9, 0.9, 0.9], None, "Icon")).collect();
|
||||
let levels = id_of(&exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 4, "inWhite": 250, "gamma": 1.05})));
|
||||
|
||||
// ---- assemble the tree (children bottom-first) --------------------------------------------
|
||||
let mut doc = (*s.active().expect("doc").doc).clone();
|
||||
let mut t = |id: LayerId| take(&mut doc, id);
|
||||
let mut header_kids = vec![t(rule), t(logo)];
|
||||
let nav_layers: Vec<Layer> = nav.iter().map(|&i| t(i)).collect();
|
||||
let mut nav_group = Layer::group("Navigation", nav_layers);
|
||||
nav_group.blend = BlendMode::Normal; // isolated
|
||||
header_kids.push(nav_group);
|
||||
let header = Layer::group("Header", header_kids);
|
||||
|
||||
let mut headline_l = t(headline);
|
||||
headline_l.effects.items = vec![drop_shadow(px(12.0) as f32, px(24.0) as f32)];
|
||||
let mut cta_l = t(cta_bg);
|
||||
cta_l.effects.items = vec![drop_shadow(px(6.0) as f32, px(12.0) as f32)];
|
||||
let mut hue_l = t(hue);
|
||||
hue_l.clipped = true;
|
||||
let button = Layer::group("CTA", vec![cta_l, t(cta_txt)]);
|
||||
let hero_photo = t(hero_img_id);
|
||||
let mut hero = Layer::group("Hero", vec![hero_photo, hue_l, headline_l, t(subhead), button]);
|
||||
if let LayerContent::Group(g) = &mut hero.content {
|
||||
g.expanded = false;
|
||||
}
|
||||
let hero_id = hero.id;
|
||||
|
||||
let mut card_groups = Vec::new();
|
||||
let z = LayerId(0);
|
||||
let mut handles = Handles { pixel: z, text: z, shape: z, group: z, smart: z, closed_group: z };
|
||||
for (i, c) in cards.iter().enumerate() {
|
||||
let mut bg = t(c[0]);
|
||||
bg.effects.items = vec![drop_shadow(px(8.0) as f32, px(18.0) as f32)];
|
||||
let mut img = t(c[1]);
|
||||
// Image masked to the card's top (layer mask).
|
||||
let b = photocraft_compose::layer_bounds(&img, Rect::new(0, 0, w, h));
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(b.x0, b.y1 - (b.height() as i32 / 6), b.x1, b.y1), &[0.0]);
|
||||
img.mask = Some(m);
|
||||
let mut title = t(c[2]);
|
||||
if i % 3 == 0 {
|
||||
title.effects.items = vec![stroke_fx(2.0, [1.0, 1.0, 1.0])];
|
||||
}
|
||||
let mut badge = t(c[5]);
|
||||
badge.effects.items = vec![gradient_overlay([1.0, 0.8, 0.2], [0.9, 0.3, 0.1])];
|
||||
if i == 0 {
|
||||
handles.smart = img.id;
|
||||
handles.text = title.id;
|
||||
handles.shape = badge.id;
|
||||
}
|
||||
let g = Layer::group(format!("Card {}", i + 1), vec![bg, img, title, t(c[3]), t(c[4]), badge]);
|
||||
if i == 0 {
|
||||
handles.group = g.id;
|
||||
}
|
||||
card_groups.push(g);
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for (r, chunk) in card_groups.chunks(cols).enumerate() {
|
||||
let mut row = Layer::group(format!("Row {}", r + 1), chunk.to_vec());
|
||||
row.blend = if r.is_multiple_of(2) { BlendMode::PassThrough } else { BlendMode::Normal };
|
||||
rows.push(row);
|
||||
}
|
||||
let products = Layer::group("Products", rows);
|
||||
|
||||
let mut tile_layers = Vec::new();
|
||||
for (i, mut l) in tiles.into_iter().enumerate() {
|
||||
let b = photocraft_compose::layer_bounds(&l, Rect::new(0, 0, w, h));
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(b.x0, b.y0, b.x0 + b.width() as i32 / 8, b.y1), &[0.0]);
|
||||
l.mask = Some(m);
|
||||
if i == 0 {
|
||||
handles.pixel = l.id;
|
||||
}
|
||||
tile_layers.push(l);
|
||||
}
|
||||
tile_layers.push(t(gallery_title));
|
||||
let mut curves_l = t(curves);
|
||||
curves_l.opacity = 0.8;
|
||||
tile_layers.push(curves_l);
|
||||
let gallery = Layer::group("Gallery", tile_layers);
|
||||
|
||||
let mut footer_kids = vec![t(foot_bg)];
|
||||
footer_kids.extend(foot.iter().map(|&i| t(i)));
|
||||
let icons: Vec<Layer> = foot_icons.iter().map(|&i| t(i)).collect();
|
||||
footer_kids.push(Layer::group("Social", icons));
|
||||
let mut footer = Layer::group("Footer", footer_kids);
|
||||
if let LayerContent::Group(g) = &mut footer.content {
|
||||
g.expanded = false;
|
||||
}
|
||||
let lv = t(levels);
|
||||
let background = doc.layers.remove(0);
|
||||
assert!(doc.layers.is_empty(), "unassembled layers: {:?}", doc.layers.iter().map(|l| &l.name).collect::<Vec<_>>());
|
||||
doc.layers = vec![background, gallery, products, hero, header, footer, lv];
|
||||
handles.closed_group = hero_id;
|
||||
(doc, handles)
|
||||
}
|
||||
|
||||
/// Every layer in the tree.
|
||||
pub fn count(doc: &Document) -> (usize, usize) {
|
||||
let all = doc.walk();
|
||||
let texts = all.iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).count();
|
||||
(all.len(), texts)
|
||||
}
|
||||
Reference in new issue
Block a user