mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Path: Object › Path › Smooth smooths the selected paths with a slider and Auto-Smooth (#4115)
This commit is contained in:
@@ -14,7 +14,10 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, PathfinderOp};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or,
|
||||
str_param, switch_or,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -153,6 +156,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
simplify
|
||||
),
|
||||
cmd!(
|
||||
"object.path.smooth",
|
||||
"Smooth…",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{level?: 0..100 (50; the Smooth slider: 0 minimum smoothing, the path staying within 0.1% of its longer side, 100 maximum: refitted with curves within up to 2.5% of it, corners kept only where the path turns back, never with more anchor points), auto?: bool (Auto-Smooth: level 50), ids?: [id…] (default: the selection)} smooth each path under the objects, compound path members included, as one undo step → {ids, before, after} anchor counts, and the level used",
|
||||
has_selection,
|
||||
smooth
|
||||
),
|
||||
cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points),
|
||||
cmd!(
|
||||
"object.path.divideObjectsBelow",
|
||||
@@ -957,20 +969,20 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
/// selected segments when they were picked as segments). `None` selects the path whole.
|
||||
type PartSelection = Option<(BTreeSet<AnchorRef>, Option<BTreeSet<SegmentRef>>)>;
|
||||
|
||||
/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its
|
||||
/// Replace each path under `roots` ([`path_ids`]) by what `f` makes of it, given its
|
||||
/// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how
|
||||
/// many anchors they have before and after). A path that `f` would empty or returns unchanged is
|
||||
/// left as it is, with its live shape and its direct selection; a replaced path loses its live
|
||||
/// shape and has the direct selection `f` gives it.
|
||||
fn edit_paths(
|
||||
s: &mut Session,
|
||||
roots: &[NodeId],
|
||||
label: &str,
|
||||
f: impl Fn(&PathData, Option<&BTreeSet<AnchorRef>>, Option<&BTreeSet<SegmentRef>>) -> (PathData, PartSelection),
|
||||
) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let (d, sel) = (&st.doc, &st.selection);
|
||||
let ids = path_ids(d, &roots);
|
||||
let ids = path_ids(d, roots);
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other(format!("{label}: select paths")));
|
||||
}
|
||||
@@ -1064,7 +1076,8 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let whole = bool_or(p, "wholePaths", false);
|
||||
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
|
||||
// other anchors it had selected) direct-selected.
|
||||
let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| {
|
||||
let roots = selected_roots(s)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Simplify", |path, anchors, segments| {
|
||||
let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else {
|
||||
return (po::simplify_with(path, &opts), None);
|
||||
};
|
||||
@@ -1084,8 +1097,20 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Object › Path › Smooth (`object.path.smooth`): [`po::smooth_level`] on each path under the
|
||||
/// objects given or selected, as one undo step; Auto-Smooth (`auto`) uses [`po::SMOOTH_AUTO_LEVEL`].
|
||||
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.path.smooth";
|
||||
let level = number_or(p, "level", po::SMOOTH_AUTO_LEVEL, C)?.round().clamp(0.0, 100.0);
|
||||
let level = if switch_or(p, "auto", false, C)? { po::SMOOTH_AUTO_LEVEL } else { level };
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Smooth", |path, _, _| (po::smooth_level(path, level), None))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after, "level": level }))
|
||||
}
|
||||
|
||||
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?;
|
||||
let roots = selected_roots(s)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use journal::Journal;
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
pub use vectorcraft_doc as doc;
|
||||
pub use vectorcraft_doc::cancel;
|
||||
pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance};
|
||||
pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, simplify_level_tolerance};
|
||||
pub use vectorcraft_tools as tools;
|
||||
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
|
||||
@@ -140,6 +140,7 @@ mod shortcutset;
|
||||
mod simplify_part;
|
||||
mod slices;
|
||||
mod smartguides;
|
||||
mod smooth_path;
|
||||
mod store;
|
||||
mod strokegeom;
|
||||
mod strokegradient;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Object › Path › Smooth (`object.path.smooth`): the slider from minimum to maximum smoothing and
|
||||
//! Auto-Smooth, as the Smooth slider help page describes
|
||||
//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::{ParamCurve, PathData, Point};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn path_of(s: &Session, id: NodeId) -> PathData {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { path, .. } => path.clone(),
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path through `points`, selected: its id.
|
||||
fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect();
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A sine wave 300 pt long with an anchor every 3 pt, its anchors 1.5 pt above and below it in turn.
|
||||
fn jagged_wave(s: &mut Session) -> NodeId {
|
||||
let pts: Vec<(f64, f64)> = (0..=100u32)
|
||||
.map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin() + if i.is_multiple_of(2) { 1.5 } else { -1.5 }))
|
||||
.collect();
|
||||
polyline(s, &pts, false)
|
||||
}
|
||||
|
||||
/// A closed ring of radius `r` around (`cx`, 300) with 120 anchors, `jag` pt in and out in turn.
|
||||
fn jagged_ring(s: &mut Session, cx: f64, r: f64, jag: f64) -> NodeId {
|
||||
let pts: Vec<(f64, f64)> = (0..120u32)
|
||||
.map(|i| {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / 120.0;
|
||||
let rr = r + if i.is_multiple_of(2) { jag } else { -jag };
|
||||
(cx + rr * a.cos(), 300.0 + rr * a.sin())
|
||||
})
|
||||
.collect();
|
||||
polyline(s, &pts, true)
|
||||
}
|
||||
|
||||
/// The farthest that 11 points on each segment of `from` lie from the nearest point of `to`.
|
||||
fn farthest(from: &PathData, to: &PathData) -> f64 {
|
||||
let off = |p: Point| {
|
||||
to.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i).nearest(p, 1e-9).distance_sq.sqrt()))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
from.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..=10).map(move |k| sp.segment(i).eval(f64::from(k) / 10.0))))
|
||||
.map(off)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_smoothing_leaves_the_path_where_it_was() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let out = s.execute("object.path.smooth", &json!({"level": 0})).unwrap();
|
||||
assert_eq!(out["level"], json!(0.0));
|
||||
let after = path_of(&s, id);
|
||||
// 0.1 % of the longer side (300 pt), both ways.
|
||||
assert!(farthest(&before, &after) <= 0.3 + 1e-6 && farthest(&after, &before) <= 0.3 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_smoothing_removes_the_jags_and_keeps_the_shape() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let mut counts = vec![];
|
||||
for level in [25, 50, 100] {
|
||||
let out = s.execute("object.path.smooth", &json!({ "level": level })).unwrap();
|
||||
let after = path_of(&s, id);
|
||||
assert_eq!(out["after"].as_u64().unwrap() as usize, after.anchor_count());
|
||||
counts.push(after.anchor_count());
|
||||
// Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away.
|
||||
assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after));
|
||||
// The end points of an open path stay put.
|
||||
let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors);
|
||||
assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p));
|
||||
assert!(!after.subpaths[0].closed);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(counts[0] < 101 / 2 && counts[2] <= 10, "{counts:?}");
|
||||
assert!(counts.windows(2).all(|w| w[0] >= w[1]), "{counts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_smooth_uses_the_middle_level() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let out = s.execute("object.path.smooth", &json!({"auto": true, "level": 0})).unwrap();
|
||||
assert_eq!(out["level"], json!(50.0));
|
||||
let auto = path_of(&s, id);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.path.smooth", &json!({"level": 50})).unwrap();
|
||||
assert_eq!(path_of(&s, id), auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_paths_and_compound_path_members_smooth_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let outer = jagged_ring(&mut s, 300.0, 100.0, 2.0);
|
||||
let inner = jagged_ring(&mut s, 300.0, 40.0, 1.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
let (o, i) = (path_of(&s, outer), path_of(&s, inner));
|
||||
let steps = undo_len(&s);
|
||||
let out = s.execute("object.path.smooth", &json!({"level": 100})).unwrap();
|
||||
assert_eq!(out["ids"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(undo_len(&s), steps + 1);
|
||||
for id in [outer, inner] {
|
||||
let now = path_of(&s, id);
|
||||
assert!(now.subpaths[0].closed);
|
||||
assert!(now.anchor_count() < 120 / 4, "{}", now.anchor_count());
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((path_of(&s, outer), path_of(&s, inner)), (o, i));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooths_the_objects_given() {
|
||||
let mut s = session();
|
||||
let a = jagged_wave(&mut s);
|
||||
let b = jagged_ring(&mut s, 300.0, 50.0, 1.0);
|
||||
let ring = path_of(&s, b);
|
||||
// `b` is selected; `ids` names `a` instead.
|
||||
let out = s.execute("object.path.smooth", &json!({"ids": [a.0], "level": 100})).unwrap();
|
||||
assert_eq!(out["ids"], json!([a.0]));
|
||||
assert_eq!(path_of(&s, b), ring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let steps = undo_len(&s);
|
||||
for p in [json!({"level": "50"}), json!({"auto": true, "level": "max"}), json!({"auto": "yes"}), json!({"ids": "all"}), json!({"ids": [999_999]})]
|
||||
{
|
||||
assert!(s.execute("object.path.smooth", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!((path_of(&s, id), undo_len(&s)), (before, steps));
|
||||
// Levels past the ends count as the ends.
|
||||
assert_eq!(s.execute("object.path.smooth", &json!({"level": 250})).unwrap()["level"], json!(100.0));
|
||||
}
|
||||
Reference in New Issue
Block a user