diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index dd867790..3286910f 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value { fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); - d.join(name) + // macOS's `/var` is a symlink to `/private/var`: a child process resolves its working + // directory through `current_dir()`, which is canonical, so compare canonical paths. + std::fs::canonicalize(&d).unwrap_or(d).join(name) } #[test] diff --git a/contributors/people.toml b/contributors/people.toml index 441b42d7..089fc24a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -36,3 +36,6 @@ display_name = "ShirokoWaWu" [people.sanyamsingh001] display_name = "sanyamsingh001" sync_github_name = false + +[people.Aavirash] +display_name = "Asquareportal" diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index d02e3fbe..83ab0f01 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha Some(build::shape_group(ids, gname, items)) } -/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space). -pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { +/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry +/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's +/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit. +pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { let bez = match kind { "rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), @@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option< effectcraft_path::from_kurbo(&bez).into_iter().next() } +/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape +/// layer, else none (a new shape layer). +pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option { + s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)) +} + /// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer), /// else the first selected unlocked shape layer, else none (a new shape layer). pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result> { let target = match p.get("layer") { Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?), - None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)), + None => selected_shape_target(s, comp), }; if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) { return Err(bad(cmd, "the layer is not a shape layer")); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index eb6ab56a..89f46d7e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -4,9 +4,10 @@ //! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and //! camera/light wireframes. +use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{Comp, Layer, LayerId}; +use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; @@ -667,16 +668,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let frame = comp.frame_rate.frame_at(time); let key = app.frame_key(cid, frame, scale); app.request_frame_urgent(cid, frame, scale); - if let Some((key, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, k)| k)) { - let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key); + // The frame on screen. While a drag or a property scrub is rendering, the exact key changes + // with every edit and its render is still in flight; the newest completed revision of the + // same frame keeps the picture following the pointer instead of freezing until it stops. + if let Some((k, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, s)| s)) { + let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k); // A CPU frame goes up again when the magnification calls for another factor. let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64); let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify); if stale || refit { - show_frame(app, &ctx, key, img, minify); - } else if let Some((_, k)) = app.viewer_shown.as_mut() { + show_frame(app, &ctx, k, img, minify); + } else if let Some((_, s)) = app.viewer_shown.as_mut() { // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. - k.revision = key.revision; + s.revision = k.revision; } } if app.ui.viewer.transparency_grid { @@ -1173,7 +1177,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { anchor_only: mods.alt, }) }), - t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + // From where the button went down: by the time the pointer has moved far enough to + // count as a drag it has already left the press point, and the shape is drawn there. + t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }), t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => { // Off the pins: Alt or a press outside the art drags a marquee that selects pins. (mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press }) @@ -1380,10 +1386,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - Gesture::Create { start, .. } => { - let a = map.to_screen(start); - let r = Rect::from_two_pos(a, pos); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); + Gesture::Create { tool, start } => { + if let Some(d) = shape_drag(app, tool, start, cpt, mods) { + draw_shape_ghost(&painter, &map, app, &d, &t); + } } Gesture::Vertices { start, src, applied, inv, layer } => { // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). @@ -1522,7 +1528,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin), Gesture::Create { tool, start } => { let end = map.to_comp(pos); - create_shape(app, tool, start, end, mods.shift); + create_shape(app, tool, start, end, mods); } Gesture::Overlay(ov::Drag::Roi { start, keep }) => { let c = map.to_comp(pos); @@ -1896,38 +1902,159 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg Some(hit) } -fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) { - let mut w = (b[0] - a[0]).abs(); - let mut h = (b[1] - a[1]).abs(); - if w < 2.0 && h < 2.0 { - return; +/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new +/// shape layer. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ShapeTarget { + Mask, + IntoLayer, + NewLayer, +} + +impl ShapeTarget { + /// What the drag will make and where, named the way the ghost captions it. + fn label(self, layer_name: &str) -> String { + match self { + ShapeTarget::Mask => format!("Mask on “{layer_name}”"), + ShapeTarget::IntoLayer => format!("Into “{layer_name}”"), + ShapeTarget::NewLayer => "New shape layer".to_string(), + } } - if square { - let m = w.max(h); - w = m; - h = m; - } - let cx = a[0].min(b[0]) + w / 2.0; - let cy = a[1].min(b[1]) + h / 2.0; - let kind = match tool { +} + +/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it +/// will land. +struct ShapeDrag { + /// The `shape.newShape` / `layer.addMask` kind for the tool. + kind: &'static str, + /// Comp-space centre and size, what the drag will make. + centre: [f64; 2], + size: [f64; 2], + target: ShapeTarget, + /// The selected layer's name and the index its next mask would take, for the ghost's caption + /// and colour. + layer_name: String, + mask_index: usize, +} + +/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. +fn shape_kind(tool: Tool) -> &'static str { + match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", Tool::Ellipse => "ellipse", Tool::Polygon => "polygon", _ => "star", - }; + } +} + +/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make +/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and +/// [`create_shape`] both go through this, so the drag always shows what it will commit. +fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let comp = app.session.active_comp()?; + let selected = app.session.state.selected_layers.first().copied(); + let sel = selected.and_then(|id| comp.layer(id)); // A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates - // Mask: a mask on it instead. - let creates_mask = app.session.state.shape_tool.creates_mask; - let sel = app.session.state.selected_layers.first().copied(); - let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); - if let Some(l) = sel_layer - && l.source.is_av() - && (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape)) - { - // Mask in layer space: invert the layer transform. - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { + // Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked + // shape layer, else a new one. + let target = match sel { + Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)), + _ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) { + Some(l) => (ShapeTarget::IntoLayer, Some(l)), + None => (ShapeTarget::NewLayer, None), + }, + }; + let (target, layer) = target; + let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 }; + let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); + // Alt doubles the box about the press point, which is then its centre. + if mods.alt { + w *= 2.0; + h *= 2.0; + } + if w < 2.0 && h < 2.0 { + return None; + } + if mods.shift { + let m = w.max(h); + w = m; + h = m; + } + Some(ShapeDrag { + kind: shape_kind(tool), + centre: if mods.alt { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] }, + size: [w, h], + target, + layer_name: layer.map_or_else(String::new, |l| l.name.clone()), + mask_index, + }) +} + +/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost. +fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 { + let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha) +} + +/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and +/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes. +fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) { + // The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it. + let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return }; + let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map); + if pts.first() == pts.last() { + pts.pop(); + } + if pts.len() < 3 { + return; + } + let tool = &app.session.state.shape_tool; + let (fill, line) = match d.target { + ShapeTarget::Mask => { + let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index); + (Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b)) + } + _ => { + let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0; + ( + if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) }, + if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent }, + ) + } + }; + let Some(bounds) = bounds_of(&pts) else { return }; + // A screen-width outline reads at every zoom, where a comp-pixel hairline does not. + painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() })); + // Caption it with where the shape lands: a mask, a group in the selected shape layer or a + // new layer. + let font = Tokens::ui(11.0); + let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line); + let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size()); + painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150)); + painter.galley(caption.min, galley, line); +} + +/// The bounds of points on screen, or `None` for an empty set. +fn bounds_of(pts: &[Pos2]) -> Option { + let first = *pts.first()?; + let mut r = Rect::from_min_max(first, first); + for p in pts.iter().skip(1) { + r.extend_with(*p); + } + Some(r) +} + +fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) { + let Some(d) = shape_drag(app, tool, a, b, mods) else { return }; + let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1])); + // A mask on the selected layer, as the ghost showed. + if d.target == ShapeTarget::Mask { + // In layer space: invert the layer transform. + let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); + if let Some(l) = sel_layer + && let Some(comp) = app.session.active_comp_arc() + { let ectx = EvalCtx { project: &app.session.project, comp_id: app.session.active_comp_id().unwrap_or_default(), @@ -1943,8 +2070,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { app.ui.status = e.to_string(); } - return; } + return; } // Into the selected shape layer's Contents as a new group (as in After Effects), else a new // shape layer; painted with the Tools bar's Fill and Stroke. diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 51a7d1e5..0038f192 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { +/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point +/// repeated for a closed path. +pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec { let k = effectcraft_engine::render::kurbo_path(sp); let mut pts: Vec = vec![]; kurbo::flatten(k.iter(), 0.5, |el| match el { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index cfb1db37..54618dc3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -368,6 +368,201 @@ fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() { assert!(!h.state().session.state.shape_tool.creates_mask); } +// ---- the shape tools' drag ghost + +/// Run frames until the viewer's background frame has landed, so the comp is painted (and not +/// still black) before the drag begins: the ghost is then the only thing that changes. +fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still +/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers +/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set. +fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage { + h.input_mut().events.push(Event::ModifiersChanged(mods)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.render().expect("the mid-drag frame") +} + +/// Release the button where the drag left it. +fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + h.run_steps(2); +} + +/// The pixel at a comp point of a rendered frame. +fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] { + let s = screen(h, p); + let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0; + [p[0], p[1], p[2]] +} + +/// How different two pixels are, summed over the channels. +fn delta(a: [u8; 3], b: [u8; 3]) -> u32 { + (0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum() +} + +/// The Tools-bar Fill as bytes, the colour a filled ghost carries. +fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { + let c = h.state().session.state.shape_tool.fill.color; + std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8) +} + +/// The shape tools show the shape they will draw under the cursor while the drag is still going: +/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was +/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box. +#[test] +fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { + let (from, to) = ([140.0, 100.0], [400.0, 300.0]); + let centre = [270.0, 200.0]; + // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. + let corner = [146.0, 106.0]; + for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { + let mut h = harness(); + h.state_mut().ui.tool = tool; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let before = h.render().expect("the frame before the drag"); + let mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let fill = fill_rgb(&h); + + // Drawn while the button is down, and painted with the Fill rather than left to a hairline. + let was = px_at(&before, &h, centre); + let now = px_at(&mid, &h, centre); + assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})"); + assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})"); + + // The tool's own form: only the Rectangle reaches the corner of its box. + let was_c = px_at(&before, &h, corner); + let now_c = px_at(&mid, &h, corner); + assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape"); + + release(&mut h, b, egui::Modifiers::default()); + } +} + +/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a +/// lie: Shift squares the box and Alt (Option) draws it from the press point, both live. +#[test] +fn shape_drag_ghost_normalises_the_same_way_as_the_release() { + let from = [140.0f64, 100.0]; + let to = [400.0f64, 260.0]; + // (mods, centre, size): Shift squares the box from its top-left, Alt centres it on the press. + for (mods, centre, size) in [ + (egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]), + (egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]), + (egui::Modifiers::ALT, [140.0, 100.0], [520.0, 320.0]), + (egui::Modifiers { alt: true, shift: true, ..Default::default() }, [140.0, 100.0], [520.0, 520.0]), + ] { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32))); + let before = h.render().expect("the frame before the drag"); + // Measured with the button down, so this is the ghost and not the committed shape. + let mid = mid_drag(&mut h, a, b, mods); + let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0]; + assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land"); + release(&mut h, b, mods); + + // And the shape it commits is that box: a new shape layer carries the position, its + // group sits at its own centre's origin. + let comp = h.state().session.active_comp().unwrap().clone(); + let layer = comp.layers.first().expect("a new shape layer").clone(); + assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer"); + let at = layer.transform().unwrap().get("position").unwrap().value.components(); + assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}"); + let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone(); + let path = group.sub("contents").unwrap().sub("ellipse").unwrap(); + let s = path.get("size").unwrap().value.components(); + assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}"); + } +} + +/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour +/// and not painted with the Fill, where a shape drawn into the selected shape layer is. +#[test] +fn shape_drag_ghost_shows_where_the_shape_will_land() { + let (from, to) = ([140.0, 100.0], [400.0, 300.0]); + let centre = [270.0, 200.0]; + + // Nothing selected: the shape becomes a new layer, painted with the Fill. + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let shape_px = px_at(&shape_mid, &h, centre); + let fill = fill_rgb(&h); + assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour"); + release(&mut h, b, egui::Modifiers::default()); + + // A solid selected: the same drag is a mask on it, in the mask colour. + let mut h = harness(); + let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap(); + h.state_mut().ui.tool = Tool::Ellipse; + settle_viewer(&mut h); + let (a, b) = (screen(&h, from), screen(&h, to)); + let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); + let mask_px = px_at(&mask_mid, &h, centre); + assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})"); + release(&mut h, b, egui::Modifiers::default()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask"); +} + +/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces +/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting +/// for it would freeze the viewer on the last completed frame for the whole drag. +#[test] +fn a_layer_drag_moves_the_picture_while_the_button_is_down() { + let mut h = harness(); + let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap(); + settle_viewer(&mut h); + let from = screen(&h, [320.0, 180.0]); + let to = screen(&h, [520.0, 180.0]); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + // Where the layer is not (yet): the plate behind it. + let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]); + + // Drag in small steps, still holding the button, and give the renders a chance to land. + let mut moved = false; + for i in 1..=40 { + let p = from + (to - from) * (i as f32 / 40.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + // 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor + // icon and where it started. + let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]); + if i >= 30 && delta(at, plate) > 30 { + moved = true; + break; + } + } + assert!(moved, "the layer's pixels did not follow the pointer while the button was down"); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + /// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the /// next shape drawn (#227). #[test]