LR-MASK-BRUSH: Shift-click draws a straight brush stroke from the last one

A Shift-click with the brush paints a straight line from where the last stroke ended, tapering size, feather and flow when they changed in between, as one undo step. mask.brushStroke takes connect: true for it.
This commit is contained in:
Michael Anderson
2026-10-14 19:11:34 -05:00
parent 3b4279fc24
commit e5c6e17bdb
6 changed files with 144 additions and 8 deletions
+66 -4
View File
@@ -150,6 +150,49 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas
Ok(json!({"activeMask": active}))
}
/// Most pieces a tapered ⇧-click segment is cut into.
const MAX_TAPER_STEPS: usize = 64;
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
/// maximum, so the pieces join without building up.
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
let Some(&first) = to.points.first() else { return vec![to] };
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
let mut points = Vec::with_capacity(to.points.len() + 1);
points.push(from);
points.extend(to.points.iter().copied());
return vec![BrushStroke { points, ..to }];
}
// about 5% of size, or 2 points of feather or flow, per piece
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
.into_iter()
.filter(|v| v.is_finite())
.fold(1.0f64, f64::max);
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
let mut out: Vec<BrushStroke> = (0..steps)
.map(|i| {
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
BrushStroke {
points: vec![at(t0), at(t1)],
size: lerp(last.size, to.size, t1),
feather: lerp(last.feather, to.feather, t1),
flow: lerp(last.flow, to.flow, t1),
..to.clone()
}
})
.collect();
if to.points.len() > 1 {
out.push(to);
}
out
}
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
}
@@ -230,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}",
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
has_active,
|s, p| {
let pts: Vec<Point> = p
@@ -250,9 +293,11 @@ pub fn specs() -> Vec<CommandSpec> {
erase: bool_or(p, "erase", false),
auto_mask: bool_or(p, "autoMask", false),
};
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
@@ -265,7 +310,20 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
// the previous stroke's end and its brush values (not its points)
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
Some((
*l.points.last()?,
BrushStroke { points: Vec::new(), size: l.size, feather: l.feather, flow: l.flow, ..BrushStroke::default() },
))
});
match anchor {
Some((from, last)) => {
strokes.extend(connected_strokes(from, &last, stroke));
connected = true;
}
None => strokes.push(stroke),
}
} else {
m.components.push(MaskComponent {
name: None,
@@ -275,7 +333,11 @@ pub fn specs() -> Vec<CommandSpec> {
});
}
Ok(())
})
})?;
if let Some(o) = out.as_object_mut() {
o.insert("connected".into(), json!(connected));
}
Ok(out)
}
),
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {