Blob Brush: Tool Options set its Fidelity

Squashed from #4414 onto main.
This commit is contained in:
dotCooCoo
2026-10-29 10:11:42 +07:00
committed by bflatastic
parent b6f916d211
commit 7f7067bcba
11 changed files with 147 additions and 18 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blob Brush",
[],
None,
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
"{points: [[x,y]…], size?: pt (10), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
has_doc,
blob
),
+15 -2
View File
@@ -373,7 +373,18 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
let region = brush_region(&pts, size);
// With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil
// stroke is fitted. Without it, or when the fitted stroke has no length (a short stroke back to
// its start), the brush follows every point.
let fidelity = match p.get("fidelity") {
None | Some(Value::Null) => None,
Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)),
};
let fitted = fidelity
.filter(|_| pts.len() > 1)
.map(|tol| po::outline_stroke(&PathData::single(fit_freehand(&pts, tol, false)), size, Cap::Round, Join::Round, 4.0))
.filter(|r| !r.is_empty());
let region = fitted.unwrap_or_else(|| brush_region(&pts, size));
if region.is_empty() {
return Err(EngineError::Other("empty brush stroke".into()));
}
@@ -392,7 +403,9 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
if merge && !(only_selection && st.selection.is_empty()) {
let all = Selection::default();
let from = if only_selection { &st.selection } else { &all };
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
// A fitted stroke can curve outside the points' bounds, so the area also takes in the shape.
let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, size), |b| b.union(poly_bounds(&pts, size)));
for id in target_paths(&st.doc, from, area) {
let Some(n) = st.doc.node(id) else { continue };
if !blob_match(n, &paint) {
continue;
+60
View File
@@ -532,6 +532,65 @@ fn blob_brush_merges_with_the_topmost_match_and_its_neighbors() {
assert_eq!(area(&path(&s, outside)), area_outside, "the path outside the group is left as it was");
}
/// Blob Brush Fidelity: the brush of `path.blob` follows the stroke fitted at `fidelity` points, as
/// `path.freehand` fits a Pencil stroke; without `fidelity` it follows every point. On a jittery
/// stroke, 0.01 pt keeps the jitter and 1.5 pt and 10 pt smooth it away, with fewer anchors and a
/// shape of about the same area. The tool's Fidelity is 1.5 pt by default.
#[test]
fn blob_brush_fidelity_fits_the_stroke_the_brush_follows() {
let mut s = session();
// A wave drawn with a hand's jitter: every other point 1 pt off.
let wave: Vec<[f64; 2]> =
(0..=80).map(|i| [20.0 + f64::from(i) * 4.0, 200.0 + 30.0 * (f64::from(i) * 0.125).sin() + if i % 2 == 0 { 1.0 } else { -1.0 }]).collect();
// The anchors and area of the blob the wave paints at height `dy` with `fidelity` (None: left out).
let mut blob = |dy: f64, fidelity: Option<f64>| {
let pts: Vec<[f64; 2]> = wave.iter().map(|q| [q[0], q[1] + dy]).collect();
let mut p = json!({"points": pts, "size": 20, "merge": false});
if let Some(f) = fidelity {
p["fidelity"] = json!(f);
}
let id = NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap());
let pd = path(&s, id);
(pd.subpaths.iter().map(|sp| sp.anchors.len()).sum::<usize>(), area(&pd))
};
let (unfitted, unfitted_area) = blob(0.0, None);
let (default, default_area) = blob(100.0, Some(1.5));
let (smooth, smooth_area) = blob(200.0, Some(10.0));
let (accurate, _) = blob(300.0, Some(0.01));
assert!(default < unfitted, "{default} anchors at 1.5 pt, {unfitted} unfitted");
assert!(smooth < default, "{smooth} anchors at 10 pt, {default} at 1.5 pt, {unfitted} unfitted");
assert!(default < accurate, "{default} anchors at 1.5 pt, {accurate} at 0.01 pt");
for (a, f) in [(default_area, 1.5), (smooth_area, 10.0)] {
assert!((a - unfitted_area).abs() < unfitted_area * 0.05, "{a} pt² at {f} pt, {unfitted_area} pt² unfitted");
}
// A short stroke back to its start that the fit leaves with no length still paints.
let back = json!({"points": [[100, 600], [110, 600], [100, 600]], "size": 10, "fidelity": 100, "merge": false});
let dab = NodeId(s.execute("path.blob", &back).unwrap()["id"].as_u64().unwrap());
assert!(area(&path(&s, dab)) > 70.0, "{}", area(&path(&s, dab)));
s.select_tool("blobBrush", view()).unwrap();
assert_eq!(s.tool_options()["fidelity"], json!(1.5));
}
/// A stroke fitted at its Fidelity can curve outside its points, and it still merges with a blob
/// of its color that the curve covers there.
#[test]
fn blob_brush_merges_with_a_blob_its_fitted_stroke_curves_over() {
let stroke = json!({"points": [[100, 100], [600, 100], [600, 600]], "size": 10, "fidelity": 1.5});
// Where the fitted shape reaches farthest outside the points' bounds padded by the size.
let mut first = session();
let id = NodeId(first.execute("path.blob", &with(&stroke, json!({"merge": false}))).unwrap()["id"].as_u64().unwrap());
let outside = |p: Point| (90.0 - p.x).max(p.x - 610.0).max(90.0 - p.y).max(p.y - 610.0);
let far =
path(&first, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).max_by(|a, b| outside(*a).total_cmp(&outside(*b))).unwrap();
assert!(outside(far) > 5.0, "the fitted stroke curves {} pt past the padded bounds", outside(far));
// A small blob there merges with the stroke.
let mut s = session();
let small = NodeId(s.execute("path.blob", &json!({"points": [[far.x, far.y]], "size": 6, "merge": false})).unwrap()["id"].as_u64().unwrap());
let merged = NodeId(s.execute("path.blob", &stroke).unwrap()["id"].as_u64().unwrap());
assert_eq!(merged, small);
assert_eq!(paths(&s).len(), 1);
}
/// Three points already curve in the saved path: both ends carry a handle into the curve.
#[test]
fn path_curvature_with_three_points_saves_end_handles() {
@@ -1373,6 +1432,7 @@ fn drawing_commands_refuse_a_size_given_as_text() {
("path.smoothRegion", "fidelity"),
("path.joinScrub", "tolerance"),
("path.blob", "size"),
("path.blob", "fidelity"),
] {
refuses_a_number_given_as_text(&mut s, cmd, &stroke, key);
}