Browse / github.com/storytold/photocraft / patch
Free Transform: preview a lone layer mask live through its layer (#9650)
Free Transform on an unlinked layer mask shows only the mesh, not the live mask When a layer mask is unlinked from the layer and targeted, pressing Ctrl/Cmd+T to transform the mask shows the transform box and a textured mesh grid (per ui-design.md: "Preview = document without the moving pixels + a textured 24x24 mesh") but not the live mask content. This makes precise sizing hard, because the mask shape is not visible while dragging. In Photoshop the mask previews live during the transform. Repro: 1. Layer with a mask; unlink the mask; target (click) the mask. 2. Ctrl/Cmd+T and scale. Expected: live mask preview during the transform. Actual: only the mesh grid is shown.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- NaufalDzz (imported from pull request #9691, open)
- Base
59cb4c49bd236df04d8e37d4764253f29ca6600a- Size
- +377 −4 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b23dc978d4851fb9ea060dc3
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/transform_cmds.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/transform_tool.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/transform_lone_mask_live.rs docs/scorecard.md scorecard/bugs.toml
@@ -161,12 +161,25 @@ pub(crate) fn move_gray_selected( warp: &dyn Fn(&Surface, Rect) -> Result<Surface>, whole: &dyn Fn(&Surface) -> Result<Surface>, ) -> Result<Surface> {- let Some((lifted, mut out)) = split_gray_selected(s, sel)? else { return whole(s) };+ let Some((lifted, out)) = split_gray_selected(s, sel)? else { return whole(s) };+ lay_moved(out, &lifted, warp)+}++/// A lone mask or channel as Free Transform leaves it, for the live preview of the box: `rest`+/// with `lifted` moved through `h` laid over it (both from [`split_gray_selected`]). Refused+/// past the transform budget, as the commit is.+pub fn moved_gray(rest: &Surface, lifted: &Surface, h: &Homography, interp: Interp) -> Result<Surface> {+ check_budget(lifted.content_bounds(), h, "mask or channel", rest.format())?;+ lay_moved(rest.clone(), lifted, &|l, src| Ok(warp_surface(l, src, h, interp)?))+}++/// `out` with the grey + alpha values of `lifted`, moved by `warp`, laid over it.+fn lay_moved(mut out: Surface, lifted: &Surface, warp: &dyn Fn(&Surface, Rect) -> Result<Surface>) -> Result<Surface> { let src = lifted.content_bounds(); if src.is_empty() { return Ok(out); }- let w = warp(&lifted, src)?;+ let w = warp(lifted, src)?; let b = w.content_bounds(); if !b.is_empty() { let moved = crate::allocation::read_region(&w, b, "compositing transformed mask pixels")?;@@ -1374,6 +1387,41 @@ mod tests { assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched"); } + /// The live preview's mask is the one the commit leaves, with and without a selection.+ #[test]+ fn moved_gray_matches_the_committed_mask() {+ for selected in [false, true] {+ let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);+ if selected {+ s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 10})).unwrap();+ }+ let st = s.active().unwrap();+ let id = st.active_layer;+ let surf = lone_target(&st.doc, id, &json!({"target": "mask"})).unwrap().unwrap().clone();+ let sel = st.doc.selection.clone().unwrap_or_else(|| {+ let mut w = Surface::new(PixelFormat::GRAY8);+ w.fill_rect(target_frame(&st.doc, &surf), &[1.0]);+ w+ });+ let (lifted, rest) = split_gray_selected(&surf, &sel).unwrap().unwrap();+ let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, 30.0, 0.0, 0.0, 1.0]);+ let live = moved_gray(&rest, &lifted, &h, Interp::Bicubic).unwrap();+ s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 7, 30], "target": "mask"})).unwrap();+ let st = s.active().unwrap();+ let committed = &st.doc.layer(id.unwrap()).unwrap().mask.as_ref().unwrap().surface;+ let area = Rect::new(0, 0, 64, 64);+ assert_eq!(live.read_region(area), committed.read_region(area), "selected: {selected}");+ }+ // Past the budget it is refused, not built.+ let s = masked(&[Rect::new(10, 10, 20, 20)], false);+ let surf = lone_target(&s.active().unwrap().doc, s.active().unwrap().active_layer, &json!({"target": "mask"})).unwrap().unwrap().clone();+ let mut sel = Surface::new(PixelFormat::GRAY8);+ sel.fill_rect(Rect::new(0, 0, 64, 64), &[1.0]);+ let (lifted, rest) = split_gray_selected(&surf, &sel).unwrap().unwrap();+ let huge = Homography([1e6, 0.0, 0.0, 0.0, 1e6, 0.0, 0.0, 0.0, 1.0]);+ assert!(moved_gray(&rest, &lifted, &huge, Interp::Bilinear).is_err());+ }+ #[test] fn targeted_linked_mask_moves_with_its_layer() { // Photoshop: a linked mask can't move without its layer.@@ -1530,7 +1530,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar && pv.doc.id == st.doc.id && st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some() {- return (pv.doc.clone(), (1 << 40) + pv.session);+ let shown = (pv.doc.clone(), crate::transform_tool::PREVIEW_BASE + pv.session);+ return crate::transform_tool::live_mask_doc(app).unwrap_or(shown); } // Layer Style dialog: show its effects live (Cancel just drops the preview). let style = app.ui.dialogs.iter().find(|d| d.kind == crate::state::DialogKind::LayerStyle);@@ -2010,6 +2011,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); }+ // Between a lone layer mask's Free Transform frames: where its moving values are.+ if seen.0 == now.0+ && let Some(st) = app.session.documents().get(idx)+ && let Some(r) = crate::transform_tool::live_mask_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)) });+ } // Between an adjustment dialog's previews (and the document): the target's area. if seen.0 == now.0 && let Some(st) = app.session.documents().get(idx)@@ -9,6 +9,7 @@ //! cancels. Undo and Redo step through the session's own changes (`Steps`), not the document's //! history. +use std::collections::VecDeque; use std::sync::Arc; use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2};@@ -91,6 +92,29 @@ pub struct TransformPreview { committing: Option<JobId>, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option<crate::cas_transform::Live>,+ /// A lone layer mask's live preview ([`live_mask_doc`]).+ live_mask: Option<LiveMask>,+}++/// Preview key of a session's `doc` (`canvas::display_doc`): `PREVIEW_BASE + session`.+pub(crate) const PREVIEW_BASE: u64 = 1 << 40;+/// Preview keys of a lone layer mask's live frames: `LIVE_MASK_BASE | id`.+const LIVE_MASK_BASE: u64 = 1 << 43;++/// The live preview of a lone layer mask (#9650): the document with the mask where the box puts+/// it now, so the canvas shows the layer masked as the commit will leave it, as Photoshop does,+/// instead of the mask's values in grey over the layer. The canvas recomposites only where the+/// moving values are ([`live_mask_damage`]).+struct LiveMask {+ /// The moving values, grey + alpha (`split_gray_selected`).+ lifted: photocraft_raster::Surface,+ /// The revision of the document the session began from.+ revision: u64,+ /// The box the latest frame was built for, and that frame (`None` when it couldn't be).+ quad: Option<[[f64; 2]; 4]>,+ shown: Option<(Arc<Document>, u64)>,+ /// Where each frame shown so far differs from `doc` (the values vacated), by preview key.+ areas: VecDeque<(u64, photocraft_geom::Rect)>, } /// What [`follow_edits`] needs to rebuild the preview document.@@ -263,6 +287,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> tool: app.ui.tool, path: None, content_aware: None,+ live_mask: None, }); app.ui.transform = Some(TransformSession { session,@@ -363,6 +388,7 @@ pub(crate) fn begin_content_aware( tool: app.ui.tool, path: None, content_aware: Some(live),+ live_mask: None, styled: None, }); app.ui.transform = Some(TransformSession {@@ -459,6 +485,10 @@ fn begin_lone( }; let (lifted, rest) = tc::split_gray_selected(&surf, sel).map_err(|e| e.to_string())?.ok_or("this channel can't be transformed")?; crate::type_tool::commit(app);+ // Alpha channels and the Quick Mask keep the grey preview: only a layer mask shows through+ // a layer.+ let revision = app.session.active().map_or(0, |st| st.revision);+ let live_mask = (target == json!("mask")).then(|| LiveMask { lifted: lifted.clone(), revision, quad: None, shown: None, areas: VecDeque::new() }); let layer = app.session.active().and_then(|st| st.active_layer).map_or(0, |l| l.0); let mut pd = (*doc).clone(); let tp = json!({ "target": target });@@ -489,6 +519,7 @@ fn begin_lone( tool: app.ui.tool, path: None, content_aware: None,+ live_mask, }); app.ui.transform = Some(TransformSession { session,@@ -510,6 +541,62 @@ fn begin_lone( Ok(()) } +/// The document to show while a lone layer mask's box is open: the mask where the box puts it+/// now ([`LiveMask`]), built again only when the box changes. `None` for other sessions, or+/// when the frame can't be built (past the transform budget, a degenerate box): the canvas then+/// shows the mask vacated and [`draw_content`] the grey values.+pub(crate) fn live_mask_doc(app: &mut PhotocraftApp) -> Option<(Arc<Document>, u64)> {+ let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_ref()?);+ let live = pv.live_mask.as_ref()?;+ if live.quad == Some(t.quad) {+ return live.shown.clone();+ }+ let key = LIVE_MASK_BASE | (app.ui.alloc_id() & (LIVE_MASK_BASE - 1));+ let started = crate::gpu_canvas::now_ms();+ let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_mut()?);+ let live = pv.live_mask.as_mut()?;+ live.quad = Some(t.quad);+ live.shown = None;+ // Bilinear keeps a frame cheap while the box moves; the commit uses the chosen interpolation.+ let interp = if t.interpolation == "nearest" { photocraft_algo::transform::Interp::Nearest } else { photocraft_algo::transform::Interp::Bilinear };+ let h = Homography::rect_to_quad(t.rect, t.quad)?;+ let mut doc = (*pv.doc).clone();+ let params = json!({ "target": t.target.clone()? });+ let surf = photocraft_engine::transform_cmds::lone_target_mut(&mut doc, Some(LayerId(t.layer)), ¶ms).ok()??;+ *surf = photocraft_engine::transform_cmds::moved_gray(surf, &live.lifted, &h, interp).ok()?;+ let (min, max) = t.quad.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), q| ([lo[0].min(q[0]), lo[1].min(q[1])], [hi[0].max(q[0]), hi[1].max(q[1])]));+ let edge = |v: f64| v.clamp(-1e9, 1e9) as i32;+ let area =+ photocraft_geom::Rect::new(edge(min[0].floor()), edge(min[1].floor()), edge(max[0].ceil()), edge(max[1].ceil())).inflate(2).intersect(&doc.bounds());+ if live.areas.len() >= 128 {+ live.areas.pop_front();+ }+ live.areas.push_back((key, area));+ live.shown = Some((Arc::new(doc), key));+ let shown = live.shown.clone();+ app.perf.span("transform mask preview", crate::gpu_canvas::now_ms() - started);+ shown+}++/// What changed between preview keys `seen` and `now` of document `doc` at `revision` while a lone+/// layer mask's box is open: where the moving values are in either frame. `None` when either key+/// isn't one of this session's (the canvas then recomposites everything).+pub(crate) fn live_mask_damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<photocraft_geom::Rect> {+ let pv = app.transform_preview.as_ref().filter(|pv| pv.doc.id == doc)?;+ let live = pv.live_mask.as_ref().filter(|l| l.revision == revision)?;+ let area = |key: u64| {+ if key == PREVIEW_BASE + pv.session { Some(photocraft_geom::Rect::EMPTY) } else { live.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) }+ };+ let (a, b) = (area(seen)?, area(now)?);+ Some(if a.is_empty() {+ b+ } else if b.is_empty() {+ a+ } else {+ a.union(&b)+ })+}+ /// Edit › Free Transform Path: the box frames the path's anchors and handles and draws the /// path through it; OK commits one `path.transform` step. fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json::Value, path: photocraft_doc::vector::Path) -> Result<(), String> {@@ -539,6 +626,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: tool: app.ui.tool, path: Some(path), content_aware: None,+ live_mask: None, above: None, follow: None, committing: None,@@ -627,6 +715,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( tool: app.ui.tool, path: None, content_aware: None,+ live_mask: None, }); app.ui.transform = Some(TransformSession { session,@@ -2231,6 +2320,8 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y)));+ } else if pv.live_mask.as_ref().is_some_and(|l| l.shown.is_some()) {+ // A lone layer mask shows in the document itself (`live_mask_doc`). } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { // A 24×24 grid keeps perspective previews straight. let n = 24;@@ -6148,6 +6239,87 @@ mod tests { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + /// #9650: the canvas shows the layer masked by the mask where the box puts it, rebuilt only+ /// when the box changes, and recomposites only where the mask moves.+ #[test]+ fn a_lone_mask_previews_live_through_its_layer() {+ let mut app = masked_app(false);+ app.ui.mask_target = true;+ begin(&mut app, &gpu_ctx()).unwrap();+ let (id, revision) = {+ let st = app.session.active().unwrap();+ (st.doc.id, st.revision)+ };+ let layer = app.session.active().unwrap().active_layer.unwrap();+ let mask_at = |d: &Document, x, y| d.layer(layer).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);+ // Green over the white Background where the mask hides the red square, none where it shows.+ let shows = |d: &Document, x, y| photocraft_compose::render(d, photocraft_geom::Rect::new(x, y, x + 1, y + 1)).px[0][1] < 0.01;+ // Untouched, the canvas shows the mask where it is, not vacated.+ let (first, k0) = crate::canvas::display_doc(&mut app, 0);+ assert!(mask_at(&first, 4, 4) < 0.01 && mask_at(&first, 30, 4) > 0.99);+ assert!(!shows(&first, 12, 12) && shows(&first, 20, 12), "x < 16 of the square is hidden");+ let xf = ViewXform {+ rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(128.0, 128.0)),+ zoom: 2.0,+ center: [32.0, 32.0],+ flip: false,+ rotation: 0.0,+ aspect: 1.0,+ };+ assert!(preview_meshes(&app, &xf).is_empty(), "no grey mesh over the layer");+ // 6 px right: the hidden part follows the box, the pixels stay.+ let t = app.ui.transform.as_mut().unwrap();+ t.quad = t.quad.map(|[x, y]| [x + 6.0, y]);+ let (moved, k1) = crate::canvas::display_doc(&mut app, 0);+ assert_ne!(k0, k1);+ assert!(!shows(&moved, 20, 12) && shows(&moved, 23, 12), "hidden up to x 22 now");+ assert!(mask_at(&moved, 2, 4) > 0.99, "the vacated strip reveals");+ assert_eq!(moved.layer(layer).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "pixels untouched");+ // The same box again builds nothing.+ let (again, k2) = crate::canvas::display_doc(&mut app, 0);+ assert_eq!(k1, k2);+ assert!(Arc::ptr_eq(&moved, &again));+ // Damage: where the mask's values are in either frame, not the whole canvas.+ assert_eq!(live_mask_damage(&app, id, revision, k0, k1), Some(photocraft_geom::Rect::new(0, 0, 24, 64)));+ let vacated = PREVIEW_BASE + app.transform_preview.as_ref().unwrap().session;+ assert_eq!(live_mask_damage(&app, id, revision, vacated, k1), Some(photocraft_geom::Rect::new(4, 0, 24, 64)));+ assert_eq!(live_mask_damage(&app, id, revision + 1, k0, k1), None, "another revision");+ assert_eq!(live_mask_damage(&app, id, revision, 12345, k1), None, "a key of no frame");+ // A box the engine refuses falls back to the grey values over the vacated mask.+ let t = app.ui.transform.as_mut().unwrap();+ t.quad = [[0.0, 0.0], [1e7, 0.0], [1e7, 1e7], [0.0, 1e7]];+ let (_, k3) = crate::canvas::display_doc(&mut app, 0);+ assert_eq!(k3, vacated);+ assert!(!preview_meshes(&app, &xf).is_empty(), "the grey values show instead");+ // The commit leaves the mask the preview showed.+ let t = app.ui.transform.as_mut().unwrap();+ t.quad = corners(t.rect).map(|[x, y]| [x + 6.0, y]);+ commit(&mut app);+ assert!(!app.ui.status_error, "{}", app.ui.status);+ let st = app.session.active().unwrap();+ for x in [2, 20, 23, 40] {+ assert!((mask_at(&st.doc, x, 4) - mask_at(&moved, x, 4)).abs() < 0.01, "x {x}");+ }+ }++ /// An alpha channel keeps the grey preview: it masks no layer.+ #[test]+ fn a_lone_alpha_channel_keeps_the_grey_preview() {+ let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));+ app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap();+ app.session+ .edit("Fixture", |doc, _| {+ doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(4, 4, 20, 20), &[1.0]);+ Ok(())+ })+ .unwrap();+ app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Alpha(0);+ begin(&mut app, &gpu_ctx()).unwrap();+ assert_eq!(app.ui.transform.as_ref().unwrap().target, Some(json!({"channel": 0})));+ assert!(app.transform_preview.as_ref().unwrap().live_mask.is_none());+ assert!(live_mask_doc(&mut app).is_none());+ }+ #[test] fn linked_mask_target_transforms_the_layer() { let mut app = masked_app(true);@@ -29,6 +29,7 @@ mod pattern_preview_canvas; mod radial_blur_center; mod remove_channel_target; mod system_locale;+mod transform_lone_mask_live; mod ui_scale_canvas; mod variables_data_sets; mod workspace_presets;new file mode 100644@@ -0,0 +1,136 @@+//! #9650: while Free Transform moves a targeted unlinked layer mask, the canvas shows the layer+//! masked by the mask where the box puts it, as Photoshop does, not the mask's values in grey+//! over the unmasked layer.+//!+//! The real app runs offscreen on wgpu with real egui pointer events. Skips when no GPU adapter+//! exists (like `drag_preview_canvas.rs`). Set `PHOTOCRAFT_TRANSFORM_MASK_EVIDENCE_DIR` to save+//! the frames as PNGs.++use egui::{Modifiers, PointerButton, Pos2};+use photocraft_ui_egui::PhotocraftApp;+use photocraft_ui_egui::canvas::ViewXform;+use photocraft_ui_egui::control::{ControlRequest, handle};+use serde_json::{Value, json};++type Harness = egui_kittest::Harness<'static, PhotocraftApp>;++fn harness() -> Option<Harness> {+ let built = std::panic::catch_unwind(|| {+ egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| {+ PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());+ app.session.prefs.edit(|p| p.interface.language = "en".into());+ if let Some(rs) = cc.wgpu_render_state.as_ref() {+ app.set_wgpu(rs.clone());+ }+ app+ })+ });+ match built {+ Ok(h) => Some(h),+ Err(_) => {+ eprintln!("skipping: no GPU adapter");+ None+ }+ }+}++fn control(h: &mut Harness, method: &str, params: Value) {+ let ctx = h.ctx.clone();+ let (req, _rx) = ControlRequest::new(method, params);+ handle(h.state_mut(), &ctx, &req);+ h.run_steps(3);+}++fn press(h: &mut Harness, p: Pos2, pressed: bool) {+ h.event(egui::Event::PointerMoved(p));+ h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });+ h.step();+}++/// The screen colours at document points `at` (y 175) in a frame of the app, saved as+/// `transform-mask-{name}.png` when evidence is asked for.+fn colors(h: &mut Harness, name: &str, at: &[f32]) -> Vec<[u8; 3]> {+ let xf = ViewXform::active(h.state()).expect("a view");+ let ppp = h.ctx.pixels_per_point();+ let img = h.render().expect("render");+ if let Some(dir) = std::env::var_os("PHOTOCRAFT_TRANSFORM_MASK_EVIDENCE_DIR") {+ let dir = std::path::PathBuf::from(dir);+ std::fs::create_dir_all(&dir).expect("evidence directory");+ img.save(dir.join(format!("transform-mask-{name}.png"))).expect("save evidence");+ }+ at.iter()+ .map(|x| {+ let p = xf.to_screen(*x, 175.0);+ let px = img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32).0;+ [px[0], px[1], px[2]]+ })+ .collect()+}++/// Checks the frame `name`: white (hidden by the mask) or blue at each x.+fn check(h: &mut Harness, name: &str, want: &[(f32, bool)]) {+ let xs: Vec<f32> = want.iter().map(|w| w.0).collect();+ for ((x, hidden), c) in want.iter().zip(colors(h, name, &xs)) {+ assert!(if *hidden { white(c) } else { blue(c) }, "{name}: x {x} reads {c:?}");+ }+}++fn white(c: [u8; 3]) -> bool {+ c.iter().all(|v| *v > 235)+}++fn blue(c: [u8; 3]) -> bool {+ c[2] > 180 && c[0] < 80 && c[1] < 80+}++#[test]+fn a_lone_mask_moves_live_through_its_layer() {+ let _gpu = photocraft_testkit::gpu_lock();+ let Some(mut h) = harness() else { return };+ h.run_steps(4);+ {+ // A blue layer over 100..300 × 100..250 on white, its unlinked mask hiding x 100..200.+ let app = h.state_mut();+ app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).expect("new");+ app.run("layer.new.layer", json!({})).expect("layer");+ app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 150})).expect("select");+ app.run("edit.fill", json!({"color": "#0000ff"})).expect("fill");+ app.run("select.deselect", json!({})).expect("deselect");+ app.run("layer.layerMask.revealAll", json!({})).expect("mask");+ app.session+ .edit("Fixture", |doc, a| {+ let m = doc.layer_mut(a.expect("layer")).expect("layer").mask.as_mut().expect("mask");+ m.surface.fill_rect(photocraft_geom::Rect::new(100, 0, 200, 300), &[0.0]);+ m.linked = false;+ Ok(())+ })+ .expect("fixture");+ app.sync_views();+ app.ui.mask_target = true;+ app.ui.extras.snap = false;+ app.ui.view.show.smart_guides = false;+ }+ control(&mut h, "ui.set", json!({"tool": "move", "zoom": 1.0, "center": [200, 150]}));+ control(&mut h, "ui.menu.invoke", json!({"id": "edit.freeTransform"}));+ assert_eq!(h.state().ui.transform.as_ref().and_then(|t| t.target.clone()), Some(json!("mask")), "the box moves the mask");+ // The mask hides x 100..200 when the box opens.+ check(&mut h, "open", &[(150.0, true), (250.0, false)]);+ let xf = ViewXform::active(h.state()).expect("a view");+ // Drag the box 60 px right (from below the reference point, which a press would move+ // instead): the hidden band follows it to x 160..260.+ let (from, to) = (xf.to_screen(150.0, 220.0), xf.to_screen(210.0, 220.0));+ press(&mut h, from, true);+ for i in 1..=8 {+ h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));+ h.step();+ }+ h.run_steps(2);+ let moved = [(130.0, false), (180.0, true), (240.0, true), (280.0, false)];+ check(&mut h, "dragged", &moved);+ press(&mut h, to, false);+ h.key_press(egui::Key::Enter);+ h.run_steps(4);+ assert!(h.state().ui.transform.is_none(), "applied");+ check(&mut h, "applied", &moved);+}@@ -14,7 +14,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | |-| [Open bugs](#open-bugs) | 31 | 1 | 0 | |+| [Open bugs](#open-bugs) | 32 | 1 | 0 | | ## Performance @@ -298,6 +298,7 @@ Never-crash attribute on 30 of 30 crate roots. | BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both | | BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_mask_with_a_selection_moves_only_the_selected_region (transform_cmds.rs; 5a5facc, #242) | | BUG-205-2 | Live transform preview draws the layer with its mask applied | done | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242) |+| BUG-205-6 | Live transform preview of a targeted unlinked mask shows the layer masked by it as the box moves it | done | [#9650](https://github.com/storytold/photocraft/issues/9650) | transform_tool.rs live_mask_doc + a_lone_mask_previews_live_through_its_layer; tests/it/transform_lone_mask_live.rs drags it in the offscreen app | | BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | targeted_unlinked_mask_transforms_alone and targeted_linked_mask_moves_with_its_layer (transform_cmds.rs) plus the through-the-box tests (transform_tool.rs; 5a5facc, #242) | | BUG-205-4 | Test: a linked raster mask with no selection moves with the pixels | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_raster_mask_moves_with_the_pixels (transform_cmds.rs): a reveal-all mask with a hidden half, linked, no selection; asserts the hidden half travels with the pixels and nothing hidden is left behind | | BUG-205-5 | Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only | done | [#205](https://github.com/storytold/photocraft/issues/205) | All three in transform_cmds.rs: linked_mask_with_a_selection_moves_only_the_selected_region checks the mask value under each moved pixel, unlinked_mask_stays_put covers it with and without a selection, targeted_unlinked_mask_transforms_alone moves the mask while the layer bounds stay |@@ -104,6 +104,13 @@ status = "done" issue = 205 note = "transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242)" +[[item]]+id = "BUG-205-6"+target = "Live transform preview of a targeted unlinked mask shows the layer masked by it as the box moves it"+status = "done"+issue = 9650+note = "transform_tool.rs live_mask_doc + a_lone_mask_previews_live_through_its_layer; tests/it/transform_lone_mask_live.rs drags it in the offscreen app"+ [[item]] id = "BUG-205-3" target = "edit.transform honours target mask: transforms only a targeted, unlinked mask"