mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Blob Brush: Tool Options set its Fidelity
Squashed from #4414 onto main.
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user