Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for … (#453)
* Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for visibility * Liquify Freeze Lasso: scanline fill, tests, one translated tooltip, theme colours - apply_lasso fills node rows with an even-odd scanline pass (O(rows x edges) plus the nodes written) instead of a point-in-polygon test per node; non-finite points are skipped and huge or off-canvas polygons clamp to the field. - LassoMask joins LiquifyTool::ALL, so the strip draws its button in the loop; its tooltip is one translated string (all catalogs); the outline uses theme tokens. - A closed lasso clears Redo (#435). - filter.liquify params doc lists lassoMask. - Tests: concave polygon vs. a reference even-odd test at several cell sizes, thaw, degenerate and hostile polygons, replay; the UI lasso records a stroke, Alt thaws, undo/redo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: JJ <jj@dot.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
48bd08150e
commit
bd7d47bf0f
|
|
@ -38,6 +38,7 @@ pub enum LiquifyTool {
|
|||
PushLeft,
|
||||
Freeze,
|
||||
Thaw,
|
||||
LassoMask,
|
||||
/// The Reconstruct… button: scales the whole (unfrozen) field toward zero by `amount` %.
|
||||
ReconstructAll,
|
||||
/// Mask Options › Mask All / None / Invert All (whole-field freeze edits).
|
||||
|
|
@ -47,7 +48,7 @@ pub enum LiquifyTool {
|
|||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
|
|
@ -58,6 +59,7 @@ impl LiquifyTool {
|
|||
LiquifyTool::PushLeft,
|
||||
LiquifyTool::Freeze,
|
||||
LiquifyTool::Thaw,
|
||||
LiquifyTool::LassoMask,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
|
|
@ -72,6 +74,7 @@ impl LiquifyTool {
|
|||
LiquifyTool::PushLeft => "Push Left",
|
||||
LiquifyTool::Freeze => "Freeze Mask",
|
||||
LiquifyTool::Thaw => "Thaw Mask",
|
||||
LiquifyTool::LassoMask => "Freeze Lasso",
|
||||
LiquifyTool::ReconstructAll => "Reconstruct All",
|
||||
LiquifyTool::FreezeAll => "Mask All",
|
||||
LiquifyTool::ThawAll => "Mask None",
|
||||
|
|
@ -86,7 +89,7 @@ impl LiquifyTool {
|
|||
|
||||
/// Tools that act while the brush is held still (each recorded point is a dab).
|
||||
pub fn is_stationary(self) -> bool {
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft)
|
||||
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft | LiquifyTool::LassoMask)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +127,8 @@ pub struct LiquifyStroke {
|
|||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
pub points: Vec<Vec<f64>>,
|
||||
/// `reconstructAll` only: how much to restore, 0..100.
|
||||
/// `reconstructAll` only: how much to restore, 0..100. `lassoMask`: 1.0/none freezes the
|
||||
/// polygon, 0.0 thaws it.
|
||||
#[serde(default)]
|
||||
pub amount: Option<f64>,
|
||||
}
|
||||
|
|
@ -216,6 +220,7 @@ impl LiquifyField {
|
|||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
let k = (s.amount.unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
for (v, f) in self.d.iter_mut().zip(&self.freeze) {
|
||||
|
|
@ -242,6 +247,60 @@ impl LiquifyField {
|
|||
dirty
|
||||
}
|
||||
|
||||
/// Lasso: sets the freeze mask to `amount` (1.0 when none; 0.0 thaws) at every field node
|
||||
/// inside the closed polygon in `s.points` (even-odd rule; non-finite points are skipped).
|
||||
/// A scanline fill: each node row costs one pass over the edges, then only the nodes inside
|
||||
/// are written. Returns the document rectangle the mask changed in.
|
||||
fn apply_lasso(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
let poly: Vec<[f64; 2]> = s.points.iter().filter_map(|p| Some([*p.first()?, *p.get(1)?])).filter(|p| p[0].is_finite() && p[1].is_finite()).collect();
|
||||
if poly.len() < 3 || self.w == 0 || self.h == 0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let (mut px0, mut py0, mut px1, mut py1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in &poly {
|
||||
px0 = px0.min(p[0]);
|
||||
py0 = py0.min(p[1]);
|
||||
px1 = px1.max(p[0]);
|
||||
py1 = py1.max(p[1]);
|
||||
}
|
||||
let (_, gy0) = self.to_grid(px0, py0);
|
||||
let (_, gy1) = self.to_grid(px1, py1);
|
||||
let last_row = self.h.saturating_sub(1) as f64;
|
||||
let last_col = self.w.saturating_sub(1) as f64;
|
||||
let (j0, j1) = (gy0.ceil().max(0.0), gy1.floor().min(last_row));
|
||||
if j1 < j0 {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let v = if s.amount.unwrap_or(1.0) > 0.5 { 1.0f32 } else { 0.0 };
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for j in (j0 as usize)..=(j1 as usize) {
|
||||
let y = self.node_pos(0, j)[1];
|
||||
xs.clear();
|
||||
let Some(mut prev) = poly.last().copied() else { return Rect::EMPTY };
|
||||
for &a in &poly {
|
||||
if (a[1] > y) != (prev[1] > y) {
|
||||
xs.push(a[0] + (y - a[1]) * (prev[0] - a[0]) / (prev[1] - a[1]));
|
||||
}
|
||||
prev = a;
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
// Even-odd: nodes with xs[2k] <= x < xs[2k + 1] are inside.
|
||||
for &[xa, xb] in xs.as_chunks::<2>().0 {
|
||||
let (ga, _) = self.to_grid(xa, y);
|
||||
let (gb, _) = self.to_grid(xb, y);
|
||||
let (i0, i1) = (ga.ceil().max(0.0), (gb.ceil() - 1.0).min(last_col));
|
||||
if i1 < i0 {
|
||||
continue;
|
||||
}
|
||||
let row = j * self.w;
|
||||
if let Some(cells) = self.freeze.get_mut(row + i0 as usize..=row + i1 as usize) {
|
||||
cells.fill(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Rect::new(px0.floor() as i32, py0.floor() as i32, px1.ceil() as i32, py1.ceil() as i32).intersect(&self.bounds)
|
||||
}
|
||||
|
||||
/// Starts a stroke at `p` (`[x, y, pressure]`): stationary tools dab once there. Interactive
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
|
|
@ -276,6 +335,9 @@ impl LiquifyField {
|
|||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
|
|
@ -699,6 +761,85 @@ mod tests {
|
|||
a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn lasso(points: &[[f64; 2]], amount: Option<f64>) -> LiquifyStroke {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
|
||||
s.points = points.iter().map(|p| p.to_vec()).collect();
|
||||
s.amount = amount;
|
||||
s
|
||||
}
|
||||
|
||||
/// Reference even-odd test (ray casting), to check the scanline fill against.
|
||||
fn inside(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
|
||||
let mut inside = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for (i, a) in poly.iter().enumerate() {
|
||||
let b = poly[j];
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_freezes_exactly_the_nodes_inside_a_concave_polygon() {
|
||||
// A concave "C" with a notch, at a non-integer cell size.
|
||||
let poly = [[10.0, 8.0], [70.0, 8.0], [70.0, 20.0], [30.0, 22.5], [33.0, 40.0], [80.0, 41.0], [60.0, 58.0], [9.0, 55.0]];
|
||||
for cell in [1.0, 2.0, 3.5] {
|
||||
let mut f = LiquifyField::new(bounds(), cell);
|
||||
let dirty = f.apply_stroke(&lasso(&poly, None));
|
||||
assert_eq!(dirty, Rect::new(9, 8, 80, 58));
|
||||
for j in 0..f.h {
|
||||
for i in 0..f.w {
|
||||
let want = if inside(f.node_pos(i, j), &poly) { 1.0 } else { 0.0 };
|
||||
assert_eq!(f.freeze[j * f.w + i], want, "cell {cell}: node ({i}, {j}) at {:?}", f.node_pos(i, j));
|
||||
}
|
||||
}
|
||||
assert!(f.freeze.contains(&1.0));
|
||||
// Strokes replay to the same field (undo rebuilds from the list).
|
||||
assert_eq!(LiquifyField::from_strokes(bounds(), cell, &[lasso(&poly, None)]), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_thaws_with_amount_zero() {
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.apply_stroke(&LiquifyStroke::new(LiquifyTool::FreezeAll, 1.0));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
let square = [[20.0, 20.0], [40.0, 20.0], [40.0, 40.0], [20.0, 40.0]];
|
||||
f.apply_stroke(&lasso(&square, Some(0.0)));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 0.0, "thawed inside");
|
||||
assert_eq!(f.freeze_at(5.0, 5.0), 1.0, "still frozen outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_ignores_degenerate_and_hostile_polygons() {
|
||||
let empty = LiquifyField::new(bounds(), 2.0);
|
||||
let cases: [&[[f64; 2]]; 6] = [
|
||||
&[],
|
||||
&[[10.0, 10.0], [50.0, 50.0]],
|
||||
&[[f64::NAN, 10.0], [50.0, f64::INFINITY], [20.0, 30.0]],
|
||||
// Entirely outside the canvas, on every side.
|
||||
&[[-50.0, -50.0], [-10.0, -50.0], [-10.0, -10.0]],
|
||||
&[[200.0, 10.0], [300.0, 10.0], [250.0, 50.0]],
|
||||
&[[10.0, 1e12], [50.0, 1e12], [30.0, 2e12]],
|
||||
];
|
||||
for poly in cases {
|
||||
let mut f = empty.clone();
|
||||
assert_eq!(f.apply_stroke(&lasso(poly, None)), Rect::EMPTY, "{poly:?}");
|
||||
assert_eq!(f, empty, "{poly:?}");
|
||||
}
|
||||
// Huge coordinates around the canvas clamp to it instead of overflowing.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[-1e15, -1e15], [1e15, -1e15], [1e15, 1e15], [-1e15, 1e15]], None));
|
||||
assert!(f.freeze.iter().all(|&v| v == 1.0));
|
||||
// A NaN point among good ones is skipped.
|
||||
let mut f = empty.clone();
|
||||
f.apply_stroke(&lasso(&[[10.0, 10.0], [f64::NAN, 0.0], [50.0, 10.0], [50.0, 50.0], [10.0, 50.0]], None));
|
||||
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_strokes_is_identity_at_every_depth() {
|
||||
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
|||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (reconstructAll)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
|
||||
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
|
||||
const P: &str = r##"{"pins":[{"src":[x,y],"dst":[x,y],"rotate":deg?,"depth":n?}…],"mode":"rigid|normal|distort"="normal","density":"fewer|normal|more"="normal","expansion":px=2,"interpolation":"bicubic|bilinear|nearest","layer":id?} — mesh over the opaque region, as-rigid-as-possible; on a smart object it becomes a smart filter"##;
|
||||
const Q: &str = r##"{"planes":[{"src":[[x,y]×4],"dst":[[x,y]×4]}…] (corners clockwise from top-left; corners that coincide in src are linked),"straighten":"horizontal|vertical|auto"?,"interpolation":"bicubic|bilinear|nearest","layer":id?}"##;
|
||||
vec![
|
||||
|
|
|
|||
|
|
@ -2014,3 +2014,4 @@
|
|||
Details Podrobnosti
|
||||
|
||||
Rendering mode Režim vykreslování
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso zmrazení: tažením zmrazíte oblast, tažením s klávesou Alt ji rozmrazíte (L)
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2088,3 +2088,4 @@
|
|||
Details Detalles
|
||||
|
||||
Rendering mode Modo de renderizado
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lazo de congelación: arrastre para congelar un área; Alt+arrastrar para descongelarla (L)
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2004,3 +2004,4 @@
|
|||
Details 詳細
|
||||
|
||||
Rendering mode レンダリングモード
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) フリーズなげなわ:ドラッグで範囲をフリーズ、Alt+ドラッグで解凍 (L)
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2282,3 +2282,4 @@
|
|||
GPU GPU
|
||||
|
||||
Rendering mode Режим отрисовки
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Лассо заморозки: перетащите, чтобы заморозить область; Alt+перетаскивание размораживает (L)
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -1898,3 +1898,4 @@
|
|||
Brush picker 画笔选择器
|
||||
Erase 擦除
|
||||
Right click with painting tools 使用绘画工具时右键单击
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 冻结套索:拖动以冻结区域,按住 Alt 拖动以解冻 (L)
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -1996,3 +1996,4 @@
|
|||
Details 詳細資訊
|
||||
|
||||
Rendering mode 渲染模式
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 凍結套索:拖曳以凍結區域,按住 Alt 拖曳以解凍 (L)
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -127,6 +127,8 @@ pub struct LiquifyDialog {
|
|||
redo: Vec<LiquifyStroke>,
|
||||
/// The stroke being drawn and its last point.
|
||||
cur: Option<(LiquifyStroke, [f64; 3])>,
|
||||
/// The lasso polygon being drawn (document px) and whether it thaws (Alt held at pointer-down).
|
||||
lasso: Option<(bool, Vec<[f64; 2]>)>,
|
||||
proxy: ProxyImage,
|
||||
out: Vec<[u8; 4]>,
|
||||
tex: Option<TextureHandle>,
|
||||
|
|
@ -222,6 +224,28 @@ impl LiquifyDialog {
|
|||
}
|
||||
}
|
||||
|
||||
/// Closes the lasso polygon into the freeze mask, recorded like any stroke (so undo, replay
|
||||
/// and OK all treat it the same as Freeze/Thaw brush work).
|
||||
fn close_lasso(&mut self, subtract: bool, mut pts: Vec<[f64; 2]>) {
|
||||
if pts.len() < 3 {
|
||||
return;
|
||||
}
|
||||
if let (Some(&first), Some(&last)) = (pts.first(), pts.last())
|
||||
&& ((first[0] - last[0]).abs() > 0.5 || (first[1] - last[1]).abs() > 0.5)
|
||||
{
|
||||
pts.push(first);
|
||||
}
|
||||
self.redo.clear();
|
||||
let mut s = self.template();
|
||||
s.tool = LiquifyTool::LassoMask;
|
||||
s.amount = Some(if subtract { 0.0 } else { 1.0 });
|
||||
s.points = pts.into_iter().map(|p| p.to_vec()).collect();
|
||||
let d = self.field.apply_stroke(&s);
|
||||
self.strokes.push(s);
|
||||
self.mark(d, true);
|
||||
self.render_dirty();
|
||||
}
|
||||
|
||||
/// Applies a whole-field operation (Reconstruct…, mask buttons) as a stroke.
|
||||
fn global(&mut self, tool: LiquifyTool, amount: Option<f64>) {
|
||||
self.end();
|
||||
|
|
@ -264,6 +288,7 @@ impl LiquifyDialog {
|
|||
|
||||
fn restore_all(&mut self) {
|
||||
self.cur = None;
|
||||
self.lasso = None;
|
||||
self.strokes.clear();
|
||||
self.redo.clear();
|
||||
self.rebuild();
|
||||
|
|
@ -330,6 +355,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
|||
strokes: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
cur: None,
|
||||
lasso: None,
|
||||
proxy,
|
||||
out,
|
||||
tex: None,
|
||||
|
|
@ -415,19 +441,34 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
|
|||
Ok(d.describe())
|
||||
}
|
||||
|
||||
/// Pointer in document coordinates (from the preview or the control channel).
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) {
|
||||
/// Pointer in document coordinates (from the preview or the control channel). Alt = subtract
|
||||
/// from the freeze mask (lasso only); Shift adds, same as no modifier.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
let now = crate::gpu_canvas::now_ms();
|
||||
let Some(d) = app.distort.liquify.as_mut() else { return };
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, pressure } => d.begin([x, y, f64::from(pressure)], now),
|
||||
ToolEvent::Down { x, y, pressure } => {
|
||||
if d.opts.tool == LiquifyTool::LassoMask {
|
||||
d.lasso = Some((mods.alt, vec![[x, y]]));
|
||||
} else {
|
||||
d.begin([x, y, f64::from(pressure)], now);
|
||||
}
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
d.hover = Some([x, y]);
|
||||
if d.cur.is_some() {
|
||||
if let Some((_, pts)) = &mut d.lasso {
|
||||
if pts.last().is_none_or(|&l| l[0] != x || l[1] != y) {
|
||||
pts.push([x, y]);
|
||||
}
|
||||
} else if d.cur.is_some() {
|
||||
d.extend([x, y, f64::from(pressure)], now);
|
||||
}
|
||||
}
|
||||
ToolEvent::Up { x, y } => {
|
||||
if let Some((subtract, pts)) = d.lasso.take() {
|
||||
d.close_lasso(subtract, pts);
|
||||
return;
|
||||
}
|
||||
if let Some((_, last)) = d.cur
|
||||
&& (last[0] != x || last[1] != y)
|
||||
{
|
||||
|
|
@ -446,6 +487,17 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
} else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) {
|
||||
d.undo();
|
||||
}
|
||||
// Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active.
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) {
|
||||
d.opts.show_mask = !d.opts.show_mask;
|
||||
}
|
||||
// Ctrl+I inverts the freeze mask (the Invert All button); Ctrl+D clears it (None).
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::I)) {
|
||||
d.global(LiquifyTool::InvertFreeze, None);
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::D)) {
|
||||
d.global(LiquifyTool::ThawAll, None);
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) {
|
||||
d.opts.size = (d.opts.size * 0.9).max(1.0);
|
||||
}
|
||||
|
|
@ -462,6 +514,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
(egui::Key::O, LiquifyTool::PushLeft),
|
||||
(egui::Key::F, LiquifyTool::Freeze),
|
||||
(egui::Key::D, LiquifyTool::Thaw),
|
||||
(egui::Key::L, LiquifyTool::LassoMask),
|
||||
];
|
||||
for (k, t) in tools {
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)) {
|
||||
|
|
@ -482,6 +535,7 @@ fn tool_icon(t: LiquifyTool) -> &'static str {
|
|||
LiquifyTool::PushLeft => "chevrons-left",
|
||||
LiquifyTool::Freeze => "lock",
|
||||
LiquifyTool::Thaw => "lock-open",
|
||||
LiquifyTool::LassoMask => "lasso",
|
||||
_ => "circle",
|
||||
}
|
||||
}
|
||||
|
|
@ -497,6 +551,7 @@ fn shortcut(t: LiquifyTool) -> &'static str {
|
|||
LiquifyTool::PushLeft => "O",
|
||||
LiquifyTool::Freeze => "F",
|
||||
LiquifyTool::Thaw => "D",
|
||||
LiquifyTool::LassoMask => "L",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
|
@ -523,6 +578,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
let mut action: Option<&str> = None;
|
||||
let mut events: Vec<ToolEvent> = Vec::new();
|
||||
let mut mods = egui::Modifiers::NONE;
|
||||
egui::Area::new(egui::Id::new("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
let Some(d) = app.distort.liquify.as_mut() else { return };
|
||||
d.upload(ctx);
|
||||
|
|
@ -543,11 +599,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0))));
|
||||
strip.spacing_mut().item_spacing.y = 4.0;
|
||||
for tool in LiquifyTool::ALL {
|
||||
let tip = format!("{} ({})", tl!(tool.label()), shortcut(tool));
|
||||
let tip = match tool {
|
||||
// The lasso works on the same freeze mask as Freeze/Thaw.
|
||||
LiquifyTool::LassoMask => tl!("Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L)").to_string(),
|
||||
_ => format!("{} ({})", tl!(tool.label()), shortcut(tool)),
|
||||
};
|
||||
if crate::icons::button(&mut strip, tool_icon(tool), 34.0, d.opts.tool == tool, &tip).clicked() {
|
||||
d.opts.tool = tool;
|
||||
}
|
||||
if matches!(tool, LiquifyTool::Smooth | LiquifyTool::PushLeft) {
|
||||
if matches!(tool, LiquifyTool::Smooth | LiquifyTool::PushLeft | LiquifyTool::Thaw) {
|
||||
strip.add_space(6.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -668,11 +728,22 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
if d.opts.show_mesh {
|
||||
draw_mesh(d, &clip, area);
|
||||
}
|
||||
// Brush outline.
|
||||
// The lasso polygon being drawn (danger colour freezes, accent thaws; the mask overlay paints the
|
||||
// applied area red like the Freeze brush).
|
||||
if let Some((subtract, pts)) = &d.lasso
|
||||
&& pts.len() > 1
|
||||
{
|
||||
let color = if *subtract { t.accent } else { t.danger };
|
||||
let line: Vec<Pos2> = pts.iter().map(|p| to_screen(d, area, *p)).collect();
|
||||
clip.add(egui::Shape::line(line, Stroke::new(1.0, color)));
|
||||
}
|
||||
// Brush outline (the lasso has none).
|
||||
if let Some(hp) = resp.hover_pos() {
|
||||
let r = d.opts.size * 0.5 * d.zoom;
|
||||
clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK));
|
||||
clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE));
|
||||
if d.opts.tool != LiquifyTool::LassoMask {
|
||||
let r = d.opts.size * 0.5 * d.zoom;
|
||||
clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK));
|
||||
clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE));
|
||||
}
|
||||
d.hover = Some(to_doc(d, area, hp));
|
||||
}
|
||||
clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
|
||||
|
|
@ -684,7 +755,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
} else if let Some(pp) = resp.interact_pointer_pos() {
|
||||
let q = to_doc(d, area, pp);
|
||||
let pr = 1.0;
|
||||
if resp.drag_started() || (resp.is_pointer_button_down_on() && d.cur.is_none()) {
|
||||
mods = ui.input(|i| i.modifiers);
|
||||
if d.lasso.is_some() {
|
||||
// A lasso in progress: collect the polygon, no brush dabs.
|
||||
if resp.dragged() {
|
||||
events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: pr });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
} else if resp.drag_started() || (resp.is_pointer_button_down_on() && d.cur.is_none()) {
|
||||
events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: pr });
|
||||
} else if resp.dragged() || resp.is_pointer_button_down_on() {
|
||||
let moved = d.cur.as_ref().is_some_and(|(_, l)| l[0] != q[0] || l[1] != q[1]);
|
||||
|
|
@ -696,13 +774,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() || (d.cur.is_some() && !ui.input(|i| i.pointer.primary_down())) {
|
||||
let q = d.cur.as_ref().map(|(_, l)| [l[0], l[1]]).unwrap_or_default();
|
||||
let primary_down = ui.input(|i| i.pointer.primary_down());
|
||||
let lasso_up = d.lasso.is_some() && !primary_down;
|
||||
if resp.drag_stopped() || lasso_up || (d.cur.is_some() && !primary_down) {
|
||||
let q = d.lasso.as_ref().and_then(|(_, pts)| pts.last().copied()).or_else(|| d.cur.as_ref().map(|(_, l)| [l[0], l[1]])).unwrap_or_default();
|
||||
events.push(ToolEvent::Up { x: q[0], y: q[1] });
|
||||
}
|
||||
});
|
||||
for ev in events {
|
||||
pointer(app, ev, egui::Modifiers::NONE);
|
||||
pointer(app, ev, mods);
|
||||
}
|
||||
match action {
|
||||
Some("ok") => commit(app),
|
||||
|
|
@ -933,6 +1013,37 @@ mod tests {
|
|||
assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 2, "redo after a new stroke is a no-op");
|
||||
}
|
||||
|
||||
/// The Freeze Lasso freezes the dragged polygon (⌥ thaws it) as one recorded stroke, and a
|
||||
/// new lasso clears Redo like any other stroke.
|
||||
#[test]
|
||||
fn freeze_lasso_records_a_stroke_and_clears_redo() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_layer();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
control(&mut app, &json!({"tool": "lassoMask"})).unwrap();
|
||||
let lasso = |app: &mut PhotocraftApp, m: egui::Modifiers, r: [f64; 4]| {
|
||||
pointer(app, ToolEvent::Down { x: r[0], y: r[1], pressure: 1.0 }, m);
|
||||
for (x, y) in [(r[2], r[1]), (r[2], r[3]), (r[0], r[3])] {
|
||||
pointer(app, ToolEvent::Move { x, y, pressure: 1.0 }, m);
|
||||
}
|
||||
pointer(app, ToolEvent::Up { x: r[0], y: r[3] }, m);
|
||||
};
|
||||
lasso(&mut app, egui::Modifiers::NONE, [20.0, 20.0, 80.0, 60.0]);
|
||||
let d = app.distort.liquify.as_ref().unwrap();
|
||||
assert_eq!(d.strokes.len(), 1);
|
||||
assert_eq!(d.field.freeze_at(50.0, 40.0), 1.0, "frozen inside");
|
||||
assert_eq!(d.field.freeze_at(5.0, 5.0), 0.0, "not outside");
|
||||
lasso(&mut app, egui::Modifiers::ALT, [40.0, 30.0, 60.0, 50.0]);
|
||||
let d = app.distort.liquify.as_ref().unwrap();
|
||||
assert_eq!(d.field.freeze_at(50.0, 40.0), 0.0, "⌥ thaws");
|
||||
assert_eq!(d.field.freeze_at(25.0, 25.0), 1.0);
|
||||
control(&mut app, &json!({"undo": true})).unwrap();
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().field.freeze_at(50.0, 40.0), 1.0, "undo replays the first lasso");
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().redo.len(), 1);
|
||||
lasso(&mut app, egui::Modifiers::NONE, [0.0, 0.0, 10.0, 10.0]);
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().redo.len(), 0, "a new lasso clears redo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liquify_redo_stack_is_bounded() {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
|
|||
Loading…
Reference in New Issue