Blob Brush: Tool Options set the brush's Angle and Roundness

Blob Brush Tool Options gain Angle (-180 to 180 degrees, 0 by default)
and Roundness (0 to 100 percent, 100 by default), with a preview of the
tip and three dabs; a drag in the preview square sets the Angle. The
elliptical tip keeps its angle on the page along a stroke, a 0 percent
tip is drawn 0.1 percent as wide as it is long, and Size set to
Pressure still varies its size. path.blob takes angle and roundness,
and both values are kept with the tool's options.

While a non-round tip drags, the Blob Brush shows the outline of the
area it covers with its length label, and an Alt drag that smooths
shows the Smooth tool's thin line.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent 958566468a
commit baaffd63e7
13 changed files with 286 additions and 31 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blob Brush",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), 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}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), angle?: ° (0, kept within −180–180: the tip's long axis counter-clockwise from horizontal, kept on the page along the stroke), roundness?: % (100, kept within 0–100: the tip's short axis in percent of its long one, 100 round; 0 is drawn as 0.1), 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
),
+24 -6
View File
@@ -373,8 +373,22 @@ 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 pressure = size_pressure(p, &samples, C)?;
let angle = number_or(p, "angle", 0.0, C)?;
let roundness = number_or(p, "roundness", 100.0, C)?;
if !angle.is_finite() || !roundness.is_finite() {
return Err(bad(C, "angle and roundness must be finite"));
}
let angle = angle.clamp(-180.0, 180.0);
let roundness = roundness.clamp(0.0, 100.0);
let tip = vectorcraft_tools::draw2::blob_tip(angle, roundness);
let squash = tip.inverse();
// Width profiles use distance along the spine, so measure pressure on the same squashed
// points that the outline reads for a non-round tip.
let mapped_samples: Vec<(Point, Option<f64>)> =
if roundness < 100.0 { samples.iter().map(|(pt, pressure)| (squash * *pt, *pressure)).collect() } else { samples.clone() };
let pressure = size_pressure(p, &mapped_samples, C)?;
let pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let mapped_pts: Vec<Point> = mapped_samples.into_iter().map(|(pt, _)| pt).collect();
// The widest the brush gets: the area merges look in.
let reach = pressure.as_ref().map_or(size, |(_, v)| size + v);
// With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil
@@ -384,9 +398,12 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)),
};
let spine = |tol: Option<f64>| match tol {
Some(tol) => PathData::single(fit_freehand(&pts, tol, false)),
None => PathData::single(SubPath::polyline(&pts, false)),
let spine = |tol: Option<f64>| {
let path = match tol {
Some(tol) => PathData::single(fit_freehand(&pts, tol, false)),
None => PathData::single(SubPath::polyline(&pts, false)),
};
if roundness < 100.0 { path.transformed(squash) } else { path }
};
let region = match (&pressure, pts.len() > 1) {
(Some((prof, variation)), true) => {
@@ -400,15 +417,16 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
}
}
// A click: one dab as wide as the pen pressed.
(Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))),
(Some((prof, variation)), false) => brush_region(&mapped_pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))),
(None, _) => {
let fitted = fidelity
.filter(|_| pts.len() > 1)
.map(|tol| po::outline_stroke(&spine(Some(tol)), size, Cap::Round, Join::Round, 4.0))
.filter(|r| !r.is_empty());
fitted.unwrap_or_else(|| brush_region(&pts, size))
fitted.unwrap_or_else(|| brush_region(&mapped_pts, size))
}
};
let region = if roundness < 100.0 { region.transformed(tip) } else { region };
if region.is_empty() {
return Err(EngineError::Other("empty brush stroke".into()));
}
+68
View File
@@ -38,6 +38,74 @@ fn paths(s: &Session) -> Vec<(NodeId, PathData)> {
v
}
#[test]
fn blob_tip_angle_changes_horizontal_extent_and_stays_page_oriented() {
let bounds = |points: serde_json::Value, angle: i32, roundness: i32| {
let mut s = session();
s.execute("path.blob", &json!({"points": points, "size": 40, "angle": angle, "roundness": roundness, "merge": false})).unwrap();
paths(&s)[0].1.control_bounds().unwrap()
};
let flat = bounds(json!([[100, 200], [300, 200]]), 0, 25);
let tilted = bounds(json!([[100, 200], [300, 200]]), 45, 25);
assert!(flat.height() < 15.0, "flat tip height: {}", flat.height());
assert!(tilted.height() > 25.0, "tilted tip height: {}", tilted.height());
let vertical = bounds(json!([[200, 100], [200, 300]]), 45, 25);
assert!((vertical.width() - tilted.height()).abs() < 2.0);
let round = bounds(json!([[100, 200], [300, 200]]), 45, 100);
assert!((round.height() - 40.0).abs() < 2.0);
}
#[test]
fn blob_pressure_peak_stays_at_the_sample_with_a_flat_tip() {
use kurbo::ParamCurveNearest;
use vectorcraft_geom::Point;
let mut s = session();
s.execute(
"path.blob",
&json!({"points": [[100, 100, 0.0], [200, 100, 1.0], [200, 200, 0.0]],
"size": 10, "sizeMode": "pressure", "sizeVariation": 8,
"roundness": 25, "merge": false}),
)
.unwrap();
let outline = &paths(&s)[0].1;
let near_peak = Point::new(200.0, 110.0);
let past_peak = Point::new(200.0, 140.0);
let distance = |p: Point| outline.to_bezpath().segments().map(|seg| seg.nearest(p, 0.01).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
assert!(distance(near_peak) > distance(past_peak), "pressure must narrow after the corner sample");
}
#[test]
fn blob_tip_zero_and_invalid_values_are_safe() {
let mut s = session();
s.execute("path.blob", &json!({"points": [[100, 100], [300, 100]], "size": 40, "roundness": 0})).unwrap();
let b = paths(&s)[0].1.control_bounds().unwrap();
assert!(b.height().is_finite() && b.height() > 0.0);
for (key, value) in [("angle", json!("NaN")), ("roundness", json!("oops"))] {
let mut p = json!({"points": [[100, 100], [300, 100]]});
p[key] = value;
assert!(s.execute("path.blob", &p).is_err(), "{key}: {p}");
}
s.execute("path.blob", &json!({"points": [[100, 200], [300, 200]], "angle": 1e300, "roundness": -1e300, "merge": false})).unwrap();
assert!(paths(&s)[1].1.control_bounds().unwrap().height().is_finite());
}
#[test]
fn blob_pressure_size_works_with_flat_tip() {
let mut s = session();
s.execute(
"path.blob",
&json!({"points": [[100, 100, 0], [200, 100, 1]], "size": 40, "sizeMode": "pressure", "sizeVariation": 15, "roundness": 25, "merge": false}),
)
.unwrap();
let variable = paths(&s)[0].1.control_bounds().unwrap();
let mut fixed = session();
fixed.execute("path.blob", &json!({"points": [[100, 100], [200, 100]], "size": 40, "roundness": 25, "merge": false})).unwrap();
let baseline = paths(&fixed)[0].1.control_bounds().unwrap();
assert!(variable.height() > baseline.height(), "{} <= {}", variable.height(), baseline.height());
assert!(variable.height() < 20.0, "flat tip height: {}", variable.height());
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
+15
View File
@@ -14,6 +14,21 @@ fn select(s: &mut Session, tool: &str) {
s.select_tool(tool, ViewInfo::default()).unwrap();
}
#[test]
fn blob_tip_options_report_and_persist() {
let mut s = session();
select(&mut s, "blobBrush");
assert_eq!((s.tool_options()["angle"].as_f64(), s.tool_options()["roundness"].as_f64()), (Some(0.0), Some(100.0)));
s.set_tool_option("angle", &json!(45));
s.set_tool_option("roundness", &json!(0));
select(&mut s, "selection");
let saved = serde_json::to_string(&s.prefs).unwrap();
let mut next = session();
next.apply_prefs(serde_json::from_str(&saved).unwrap());
select(&mut next, "blobBrush");
assert_eq!((next.tool_options()["angle"].as_f64(), next.tool_options()["roundness"].as_f64()), (Some(45.0), Some(0.0)));
}
#[test]
fn options_survive_a_tool_switch() {
let mut s = session();
+20 -2
View File
@@ -234,10 +234,20 @@ pub fn polyline(pts: &[Point]) -> BezPath {
/// whose long axis runs at `angle` degrees counter-clockwise on the page (y-down document space)
/// and whose short axis is `roundness` percent of the long one (kept within 1–100).
pub fn eraser_tip(angle: f64, roundness: f64) -> Affine {
ellipse_tip(angle, roundness.clamp(1.0, 100.0))
}
/// Map a round brush tip to a page-oriented ellipse. 0 percent Roundness is drawn as
/// 0.1 percent so its inverse is defined.
pub fn blob_tip(angle: f64, roundness: f64) -> Affine {
ellipse_tip(angle, roundness.clamp(0.1, 100.0))
}
fn ellipse_tip(angle: f64, roundness: f64) -> Affine {
let t = angle.to_radians();
let u = Vec2::new(t.cos(), -t.sin());
let v = Vec2::new(-u.y, u.x);
let r = roundness.clamp(1.0, 100.0) / 100.0;
let r = roundness / 100.0;
let (xx, xy, yy) = (u.x * u.x + r * v.x * v.x, u.x * u.y + r * v.x * v.y, u.y * u.y + r * v.y * v.y);
Affine::new([xx, xy, xy, yy, 0.0, 0.0])
}
@@ -246,7 +256,15 @@ pub fn eraser_tip(angle: f64, roundness: f64) -> Affine {
/// `roundness`) covers along `pts`: a round stroke along the points squashed by the tip's map,
/// stretched back.
pub fn eraser_area(pts: &[Point], size: f64, angle: f64, roundness: f64) -> PathData {
let stretch = eraser_tip(angle, roundness);
swept_tip_area(pts, size, eraser_tip(angle, roundness))
}
/// The filled area covered by a Blob Brush's page-oriented elliptical tip.
pub fn blob_area(pts: &[Point], size: f64, angle: f64, roundness: f64) -> PathData {
swept_tip_area(pts, size, blob_tip(angle, roundness))
}
fn swept_tip_area(pts: &[Point], size: f64, stretch: Affine) -> PathData {
let squash = stretch.inverse();
let mut round: Vec<Point> = pts.iter().map(|p| squash * *p).collect();
if let [p] = round[..] {
+29 -13
View File
@@ -24,7 +24,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use super::{FEEDBACK, eraser_area, points_json, polyline};
use super::{FEEDBACK, blob_area, eraser_area, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -71,9 +71,9 @@ pub struct GestureTool {
pub size_pressure: bool,
/// Blob Brush and Eraser Tool Options' Size Variation, in points.
pub size_variation: f64,
/// The angle of the Eraser tip's long axis, in degrees counter-clockwise from horizontal.
/// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal.
pub angle: f64,
/// The Eraser tip's short axis in percent of its long one (100: round).
/// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round).
pub roundness: f64,
/// Fill new pencil/brush strokes.
pub fill: bool,
@@ -439,6 +439,8 @@ impl GestureTool {
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeVariation": self.size_variation,
"fidelity": self.fidelity,
"angle": self.angle,
"roundness": self.roundness,
"keepSelected": self.keep_selected,
"mergeOnlySelection": self.merge_only_selection
}),
@@ -691,15 +693,27 @@ impl Tool for GestureTool {
_ => 1.0,
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
// A tip that is not round shows the outline of the area it covers.
// shortcut: at its Size even with Pressure; the release erases at the pen's widths.
if self.id == "eraser" && self.roundness < 100.0 {
let area = eraser_area(&self.points, self.size, self.angle, self.roundness);
o.push(Overlay::Path { path: area.to_bezpath(), color, width: 1.0, dashed: false });
return o;
}
// With Size set to Pressure, each step of the drag shows as wide as the pen pressed there.
if let Some(pressures) = self.size_pressures() {
// A tip that is not round shows the outline of the area it covers, each step of the drag
// as wide as the pen pressed there with Size set to Pressure.
if matches!(self.id, "eraser" | "blobBrush") && self.roundness < 100.0 && !self.smoothing {
let area = |pts: &[Point], size| {
if self.id == "blobBrush" {
blob_area(pts, size, self.angle, self.roundness)
} else {
eraser_area(pts, size, self.angle, self.roundness)
}
};
if let Some(pressures) = self.size_pressures() {
for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) {
let pressure = f64::from(p[0] + p[1]) / 2.0;
let size = super::pressure_size(self.size, self.size_variation, pressure);
o.push(Overlay::Path { path: area(seg, size).to_bezpath(), color, width: 1.0, dashed: false });
}
} else {
o.push(Overlay::Path { path: area(&self.points, self.size).to_bezpath(), color, width: 1.0, dashed: false });
}
} else if let Some(pressures) = self.size_pressures() {
// With Size set to Pressure, each step of the drag shows as wide as the pen pressed there.
for (seg, p) in self.points.windows(2).zip(pressures.windows(2)) {
let pressure = f64::from(p[0] + p[1]) / 2.0;
let width = super::pressure_size(self.size, self.size_variation, pressure) * cx.zoom;
@@ -785,6 +799,8 @@ impl Tool for GestureTool {
"size": self.size,
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeVariation": self.size_variation,
"angle": self.angle,
"roundness": self.roundness,
"keepSelected": self.keep_selected,
"mergeOnlySelection": self.merge_only_selection
})
@@ -810,7 +826,7 @@ impl Tool for GestureTool {
},
"sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0),
"angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0),
"roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(1.0, 100.0),
"roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0),
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
"altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth),
"livePreview" if self.id == "pencil" => self.live_preview = v.as_bool().unwrap_or(self.live_preview),
+47
View File
@@ -1391,6 +1391,53 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() {
assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}");
}
/// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with
/// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the
/// outline of the area it covers with the length label, and an Alt drag that smooths shows the
/// Smooth tool's thin line.
#[test]
fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) };
let mut t = GestureTool::new("blobBrush");
assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(0.0), Some(100.0)));
t.set_option("size", &json!(20));
for (angle, roundness, sent) in [(45.0, 25.0, (45.0, 25.0)), (400.0, -5.0, (180.0, 0.0)), (-400.0, 250.0, (-180.0, 100.0))] {
t.set_option("angle", &json!(angle));
t.set_option("roundness", &json!(roundness));
assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1)));
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["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1),
"{acts:?}"
);
}
// A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to
// x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below its ends.
t.set_option("angle", &json!(90));
t.set_option("roundness", &json!(25));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
let o = t.overlays(&cx);
let Some(Overlay::Path { path, width, .. }) = o.iter().find(|o| matches!(o, Overlay::Path { .. })) else { panic!("{o:?}") };
assert_eq!(*width, 1.0);
let b = vectorcraft_geom::Shape::bounding_box(path);
assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}");
assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}");
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { .. })), "{o:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
let o = t.overlays(&cx);
let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") };
assert_eq!(*width, 1.0);
assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}");
}
#[test]
fn smooth_needs_selection_and_brackets_resize() {
let (d, _) = doc_with_rect();
+1 -1
View File
@@ -42,7 +42,7 @@ const ROWS: &[Row] = &[
(&["blobBrush", "eraser"], None, &["size", "sizeMode", "sizeVariation"]),
(&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]),
// Eraser Tool Options' tip shape.
(&["eraser"], None, &["angle", "roundness"]),
(&["blobBrush", "eraser"], None, &["angle", "roundness"]),
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
(&["polygon"], None, &["sides"]),
+52 -2
View File
@@ -6,7 +6,7 @@
//! 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
//! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows
//! with its fill and stroke while it is drawn, or as a thin line until the release), `size`
//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation`
//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest
@@ -206,7 +206,11 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0));
}
if d.fields.contains_key("roundness") {
number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (1.0, 100.0));
let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 };
number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0));
}
if d.str("tool") == "blobBrush" {
blob_tip_preview(ui, d);
}
if d.fields.contains_key("sizeMode") {
blob_size(ui, d);
@@ -245,6 +249,52 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// Side of the Blob Brush tip preview's square, in points.
const TIP_PREVIEW: f32 = 64.0;
/// The Blob Brush's tip preview: a square with the tip at its Angle and Roundness and a line along
/// its long axis (a drag in the square sets the Angle), and beside it three dabs of the tip, the
/// middle one darker.
fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(egui::vec2(CONTENT_W, TIP_PREVIEW), egui::Sense::hover());
let square = egui::Rect::from_min_size(rect.min, egui::vec2(TIP_PREVIEW, TIP_PREVIEW));
let response = ui.interact(square, ui.scope_id().with("freehand-tip-angle"), egui::Sense::drag());
if response.dragged()
&& let Some(p) = response.interact_pointer_pos()
{
let delta = p - square.center();
if delta.length_sq() > 1.0 {
d.fields.insert("angle".into(), json!(f64::from((-delta.y).atan2(delta.x)).to_degrees().round()));
}
}
let painter = ui.painter_at(rect);
painter.rect_filled(square, 2.0, t.input);
painter.rect_stroke(square, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let a = d.f64("angle", 0.0).clamp(-180.0, 180.0).to_radians();
// The long axis on screen (y down: a positive angle tilts it up to the right) and the short one.
let u = egui::vec2(a.cos() as f32, -a.sin() as f32);
let v = egui::vec2(-u.y, u.x);
let long = 18.0;
let short = long * (d.f64("roundness", 100.0).clamp(0.0, 100.0) as f32 / 100.0).max(0.01);
let dab = |center: egui::Pos2, color: egui::Color32| {
let points = (0..32)
.map(|i| {
let s = std::f32::consts::TAU * i as f32 / 32.0;
center + u * (long * s.cos()) + v * (short * s.sin())
})
.collect();
painter.add(egui::Shape::convex_polygon(points, color, egui::Stroke::NONE));
};
let center = square.center();
dab(center, t.text);
painter.line_segment([center, center + u * 25.0], egui::Stroke::new(1.0, t.accent));
let strip = square.right() + 2.5 * long;
for (i, color) in [t.text_dim, t.text, t.text_dim].into_iter().enumerate() {
dab(egui::pos2(strip + i as f32 * 1.5 * long, center.y), color);
}
}
/// OK: set the tool's options.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
+23 -2
View File
@@ -7,6 +7,27 @@ fn app() -> VectorcraftApp {
app
}
#[test]
fn blob_tip_fields_confirm_and_cancel() {
let mut app = app();
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
let text = painted(&mut app);
assert!(text.contains("Angle:") && text.contains("Roundness:"), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("angle", -1.0), d.f64("roundness", -1.0)), (0.0, 100.0));
d.fields.insert("angle".into(), json!(45));
d.fields.insert("roundness".into(), json!(0));
super::super::confirm(&mut app).unwrap();
assert_eq!(
(app.session.tool_options_of("blobBrush")["angle"].as_f64(), app.session.tool_options_of("blobBrush")["roundness"].as_f64()),
(Some(45.0), Some(0.0))
);
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("roundness".into(), json!(75));
app.ui.dialog = None;
assert_eq!(app.session.tool_options_of("blobBrush")["roundness"], json!(0.0));
}
/// The labels the open dialog paints.
fn painted(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
@@ -118,8 +139,8 @@ fn each_tool_shows_its_options_and_ok_sets_them() {
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}");
// Both tools have Angle and Roundness.
assert!(text.contains("Angle:") && text.contains("Roundness:"), "{tool}: {text}");
app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(24));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of(tool)["size"].as_f64(), Some(24.0), "{tool}");
+2 -1
View File
@@ -814,7 +814,8 @@ Paintbrush, Blob Brush; on by default for the Pencil and Paintbrush and off for
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
(°, −180 to 180; 0) and `roundness` (%, 0 to 100 for the Blob Brush, where 0 is drawn as 0.1, and 1 to 100 for
the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `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
+1 -1
View File
@@ -2642,7 +2642,7 @@ naming the key; the document does not change and no undo step is recorded: `t`,
`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`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`;
`size`, `sizeVariation` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`;
`size`, `sizeVariation`, `angle`, `roundness` 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`,
+3 -2
View File
@@ -379,10 +379,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding (Curvature point display improved, #1236 in part) | 8–13 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding (Curvature point display improved, #1236 in part) | 8–13 |
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done; no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 |
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding | 7–12 |
| Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) |
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
@@ -737,6 +737,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 Angle and Roundness, 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 them: Angle from −180° to 180° (0° by default) and Roundness from 0% to 100% (100%, a round tip, by default). The elliptical tip keeps its angle on the page along a stroke, a 0% tip is drawn 0.1% as wide as it is long, and the dialog shows the tip and three dabs of it. The values are kept with the tool's options (`path.blob` `angle` and `roundness`). Not done: the Random and pen-driven variations of Angle and Roundness.
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.