From 7f7067bcba9a7cd73258d9239c9a02bea84de37a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 10:06:56 +0700 Subject: [PATCH 1/2] Blob Brush: Tool Options set its Fidelity Squashed from #4414 onto main. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/freehand.rs | 17 +++++- crates/engine/src/tests/draw2.rs | 60 ++++++++++++++++++++ crates/tools/src/draw2/gesture.rs | 7 ++- crates/tools/src/draw2/gesture/tests.rs | 17 ++++++ crates/tools/src/settings.rs | 2 +- crates/ui-egui/src/dialogs/freehand.rs | 2 +- crates/ui-egui/src/dialogs/freehand/tests.rs | 41 ++++++++++++- docs/control-protocol.md | 12 ++-- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 11 files changed, 147 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 4c992d593..57066e3b9 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 2d21e2ff6..a94f7de10 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -373,7 +373,18 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { 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 { 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; diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index f739f18b7..7d693b0b3 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -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| { + 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::(), 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); } diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index ee01a4e34..21c090f21 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -47,7 +47,7 @@ pub struct GestureTool { /// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected /// Paths' distance in points); the stroke redraws the section it ends on (#2850). redraw: Option<(NodeId, f64)>, - /// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity"). + /// Fit tolerance in points (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"). pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, @@ -388,6 +388,7 @@ impl GestureTool { json!({ "points": points_json(pts), "size": self.size, + "fidelity": self.fidelity, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection }), @@ -698,7 +699,9 @@ impl Tool for GestureTool { }) } "smooth" => json!({"fidelity": self.fidelity}), - "blobBrush" => json!({"size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}), + "blobBrush" => { + json!({"fidelity": self.fidelity, "size": self.size, "keepSelected": self.keep_selected, "mergeOnlySelection": self.merge_only_selection}) + } "eraser" => json!({"size": self.size, "angle": self.angle, "roundness": self.roundness}), _ => Value::Null, } diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 653efb6cf..25e59c67c 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -283,6 +283,23 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); } +/// The Blob Brush has a Fidelity, 1.5 pt by default and 0.01 to 100 pt, and its stroke sends it. +#[test] +fn blob_brush_sends_its_fidelity() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("blobBrush"); + assert_eq!(t.options()["fidelity"], json!(1.5)); + for (set, sent) in [(1.5, 1.5), (6.0, 6.0), (0.001, 0.01), (500.0, 100.0)] { + t.set_option("fidelity", &json!(set)); + assert_eq!(t.options()["fidelity"], json!(sent)); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["fidelity"] == json!(sent)), "{set}: {acts:?}"); + } +} + /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, /// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points /// dragged with Shift held move to the other line together, and each point follows the Shift diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 878fc607e..8548f3312 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -40,7 +40,7 @@ const ROWS: &[Row] = &[ (&["pencil"], None, &["altSmooth", "livePreview", "liveFit"]), (&["smooth"], None, &["fidelity"]), (&["blobBrush", "eraser"], None, &["size"]), - (&["blobBrush"], None, &["keepSelected", "mergeOnlySelection"]), + (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["eraser"], None, &["angle", "roundness"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index c1dda9b32..fa60dce6e 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -3,7 +3,7 @@ //! preferences. OK sets them with `tool.setOption {tool, values}`. The Pencil also has `liveFit` //! (Live curve fitting, on: its preview fits the stroke while it is drawn). //! -//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush and Smooth), `fill` (Pencil and +//! Fields: `tool`; `fidelity` (points; Pencil, Paintbrush, Smooth and Blob Brush), `fill` (Pencil and //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; Eraser), `livePreview` (Pencil: the stroke shows diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 40db4a3eb..53623a63d 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -98,7 +98,8 @@ fn each_tool_shows_its_options_and_ok_sets_them() { let pb_text = painted(&mut app); assert!(pb_text.contains("Paintbrush Tool Options")); assert!(!pb_text.contains("Alt key toggles to Smooth Tool") && !pb_text.contains("Live preview"), "{pb_text}"); - // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Size, Keep selected and Merge only with selection. + // Smooth has Fidelity only, the Eraser Size only, and the Blob Brush Fidelity, Size, Keep selected and Merge only + // with selection. app.run("tool.options", json!({"tool": "smooth"})).unwrap(); let text = painted(&mut app); assert!(text.contains("Smooth Tool Options") && text.contains("Fidelity:"), "{text}"); @@ -112,9 +113,10 @@ fn each_tool_shows_its_options_and_ok_sets_them() { for tool in ["blobBrush", "eraser"] { app.run("tool.options", json!({"tool": tool})).unwrap(); let text = painted(&mut app); - assert!(text.contains("Size:") && !text.contains("Fidelity:"), "{tool}: {text}"); - // The Blob Brush also has Keep selected and Merge only with selection. + // The Blob Brush also has Fidelity, Keep selected and Merge only with selection. let blob = tool == "blobBrush"; + assert!(text.contains("Size:"), "{tool}: {text}"); + assert_eq!(text.contains("Fidelity:"), blob, "{tool}: {text}"); assert_eq!((text.contains("Keep selected"), text.contains("Merge only with selection")), (blob, blob), "{tool}: {text}"); // The Eraser also has Angle and Roundness. assert_eq!((text.contains("Angle:"), text.contains("Roundness:")), (!blob, !blob), "{tool}: {text}"); @@ -200,6 +202,39 @@ fn a_new_fidelity_changes_what_the_pencil_draws() { assert!(smooth < precise, "{smooth} anchors at fidelity 12, {precise} at the default"); } +/// The Blob Brush's Fidelity, set in its Tool Options, changes the shape its strokes paint: at +/// 0.01 pt the brush follows a stroke's jitter, and at the default 1.5 pt it smooths it away. +#[test] +fn a_new_fidelity_changes_what_the_blob_brush_draws() { + let mut app = app(); + // The anchors of the blob a jittery wave paints around height `y0`. + let anchors = |app: &mut VectorcraftApp, y0: f64| { + app.select_tool("blobBrush"); + let view = app.view_info(); + let jitter = |i: i32| if i % 2 == 0 { 1.0 } else { -1.0 }; + let pts: Vec<_> = (0..=40).map(|i| (20.0 + f64::from(i) * 8.0, y0 + 30.0 * (f64::from(i) * 0.25).sin() + jitter(i))).collect(); + let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); + for p in &pts[1..] { + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Drag, *p), view); + } + crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Up, pts[40]), view); + let doc = &app.session.doc().unwrap().doc; + doc.layers[0].children().unwrap().last().unwrap().path_data().map_or(0, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum::()) + }; + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("fidelity", 0.0), 1.5); + super::super::confirm(&mut app).unwrap(); + let smooth = anchors(&mut app, 100.0); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.01)); + super::super::confirm(&mut app).unwrap(); + app.select_tool("selection"); + assert_eq!(app.session.tool_options_of("blobBrush")["fidelity"].as_f64(), Some(0.01), "it lasts while another tool is chosen"); + let precise = anchors(&mut app, 200.0); + assert!(smooth > 0 && smooth < precise, "{smooth} anchors at the default, {precise} at fidelity 0.01"); +} + #[test] fn the_new_path_is_selected_only_with_keep_selected_on() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b6024380d..13239298f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -799,12 +799,12 @@ events take `pressure` (0..1, default 1): it is the Liquify intensity while Use Freehand Tool Options: double-clicking the Pencil, Paintbrush, Smooth, Blob Brush or Eraser tool (`tool.options {tool: "pencil"}`) opens a `freehandOptions` dialog. Its fields are `tool` and the options the tool -keeps: `fidelity` (pt; Pencil, Paintbrush, Smooth), `fill` (Pencil, Paintbrush), `keepSelected` (Pencil, Paintbrush, -Blob Brush; on by default for the Pencil and Paintbrush and off for the Blob Brush, and while it is off the path a -stroke draws, continues or merges into is not selected afterwards), `mergeOnlySelection` (Blob Brush; off by default, -and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` (screen pixels, 0 turns -it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` (°, −180 to 180; 0) and `roundness` -(%, 1 to 100; 100) of the Eraser's tip (Eraser). `ui.dialog.confirm` runs +keeps: `fidelity` (pt; Pencil, Paintbrush, Smooth, Blob Brush), `fill` (Pencil, Paintbrush), `keepSelected` (Pencil, +Paintbrush, Blob Brush; on by default for the Pencil and Paintbrush and off for the Blob Brush, and while it is off +the path a stroke draws, continues or merges into is not selected afterwards), `mergeOnlySelection` (Blob Brush; +off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` +(screen pixels, 0 turns it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` +(°, −180 to 180; 0) and `roundness` (%, 1 to 100; 100) of the Eraser's tip (Eraser). `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the diff --git a/docs/mcp.md b/docs/mcp.md index 4be7ac337..749f56763 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2465,8 +2465,8 @@ These commands refuse a string or another value that is not a number with `` `dy naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in -`brush.freehand`, which runs it); `size`, `angle` and `roundness` in `path.eraseRegion`; `size` in `path.blob`; -`width` in `path.eraseSegments`; +`brush.freehand`, which runs it); `size`, `angle` and `roundness` in `path.eraseRegion`; +`size` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; `radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`; and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f6b2caf03..94c04a803 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -703,6 +703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Shape Builder pieces on dense hatches (lines at exact 0.25 or 0.5 pt steps across another line) come back one per stretch between crossings, with no speck where the sweep cut a line's two passes a rounding error apart: the planar map contracts the stub between such cuts into one vertex, bounded by each cluster's extent so a run of tiny level segments keeps its length (`crates/pathops/src/planar.rs` tests `a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing`, `a_run_of_tiny_level_segments_is_not_contracted_as_stubs`, `a_line_crossed_where_its_two_passes_part_is_cut_once`). The planar map's edge-source grid stays linear in memory on any input: a segment is filed once per cell it crosses, and the grid holds at most a budget of cell references (a few dozen per item plus a few rows' worth), past which the remaining items go to a short scanned list and are still found, so thousands of coincident segments cannot make the index quadratic (`crates/pathops/src/planar.rs` test `rect_index_holds_a_bounded_number_of_references`). The Blob Brush Tool Options have Keep selected and Merge only with selection, both off by default: while Keep selected is off, the blob a stroke draws or merges into is not selected afterwards, and while Merge only with selection is on, a stroke merges only with selected blobs of its color that it touches (`path.blob` `keepSelected` and `mergeOnlySelection`). + The Blob Brush Tool Options also have Fidelity, as the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list it: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`). A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`). - **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. From 0dfae85814effd3ec3b61d4bc9d4b529e243cc86 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 10:12:15 +0700 Subject: [PATCH 2/2] Blob Brush: import Cap and Join in freehand.rs (#4299 made the imports explicit) --- crates/engine/src/cmd/draw2/freehand.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index a94f7de10..e037f0cc1 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -3,7 +3,7 @@ use super::common::{ within_input_bound, }; use super::{ - Anchor, Appearance, BTreeSet, BoolOp, Color, Document, EngineError, FillRule, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, + Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, };