mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #443 from Aavirash/fix/shape-drag-preview
# Conflicts: # crates/ui-egui/src/frames.rs # crates/ui-egui/src/panels/viewer.rs
This commit is contained in:
6 files changed
+380
-43
No files matched your search
@@ -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]
|
||||
|
||||
@@ -36,3 +36,6 @@ display_name = "ShirokoWaWu"
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Aavirash]
|
||||
display_name = "Asquareportal"
|
||||
@@ -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<ShapePath> {
|
||||
/// 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<ShapePath> {
|
||||
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<LayerId> {
|
||||
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<Option<LayerId>> {
|
||||
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"));
|
||||
|
||||
@@ -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<ShapeDrag> {
|
||||
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<Rect> {
|
||||
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.
|
||||
|
||||
@@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
|
||||
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
|
||||
}
|
||||
|
||||
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
|
||||
/// 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<Pos2> {
|
||||
let k = effectcraft_engine::render::kurbo_path(sp);
|
||||
let mut pts: Vec<Pos2> = vec![];
|
||||
kurbo::flatten(k.iter(), 0.5, |el| match el {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user