mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #1949 from ham9zah/curvature-illustrator-geometry
Curvature: each three points follow the circle through them, curved ends (#1236, in part)
This commit is contained in:
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Curvature",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
has_doc,
|
||||
curvature_edit
|
||||
),
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)",
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -648,7 +648,7 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false));
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
@@ -693,6 +693,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => EndCurve::Keep,
|
||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||
};
|
||||
let corner = bool_or(p, "corner", false);
|
||||
let op = match str_param(p, "op") {
|
||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||
@@ -717,7 +718,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ai
|
||||
}
|
||||
CurvatureOp::Extend(q) => {
|
||||
curvature_extend(sp, start, q, how);
|
||||
curvature_extend(sp, start, q, how, corner);
|
||||
if start { 0 } else { sp.anchors.len() - 1 }
|
||||
}
|
||||
CurvatureOp::Close => {
|
||||
@@ -824,14 +825,19 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Convert Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if smooth && target.is_none() {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
// Without a handle position the anchor curves as the Curvature tool curves a point; one
|
||||
// with no direction to take stays as it is.
|
||||
vectorcraft_tools::draw2::curvature_smooth(sp, ai);
|
||||
return Ok(());
|
||||
}
|
||||
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
match target {
|
||||
_ if !smooth => a.retract(),
|
||||
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
Some(_) => a.retract(),
|
||||
None => {
|
||||
sp.smooth_anchor(ai);
|
||||
}
|
||||
Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
_ => a.retract(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -186,6 +186,94 @@ fn paintbrush_uses_stroke_blob_brush_fills() {
|
||||
assert!(area(&ps[1].1) > 1500.0);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let mut s = session();
|
||||
let pts = json!([{"x": 0, "y": 0}, {"x": 100, "y": -100}, {"x": 200, "y": 0}]);
|
||||
let id = NodeId(s.execute("path.curvature", &json!({ "points": pts })).unwrap()["id"].as_u64().unwrap());
|
||||
let sp = path(&s, id).subpaths[0].clone();
|
||||
assert_eq!(sp.anchors.len(), 3);
|
||||
let (first, mid, last) = (sp.anchors[0], sp.anchors[1], sp.anchors[2]);
|
||||
assert!(first.has_out() && !first.has_in(), "{first:?}");
|
||||
assert!(last.has_in() && !last.has_out(), "{last:?}");
|
||||
assert_eq!(mid.kind, vectorcraft_geom::AnchorKind::Smooth);
|
||||
assert!(!sp.segment_is_line(0) && !sp.segment_is_line(1));
|
||||
// Two points stay a straight line.
|
||||
let two = json!([{"x": 0, "y": 0}, {"x": 100, "y": -100}]);
|
||||
let id = NodeId(s.execute("path.curvature", &json!({ "points": two })).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(path(&s, id).subpaths[0].segment_is_line(0));
|
||||
}
|
||||
|
||||
/// The rubber band while drawing, and while going on from a selected path's end, is the path the
|
||||
/// click then makes.
|
||||
#[test]
|
||||
fn curvature_rubber_band_is_the_path_the_click_makes() {
|
||||
let band = |s: &mut Session| {
|
||||
s.overlays(view()).into_iter().find_map(|o| if let vectorcraft_tools::Overlay::Path { path, .. } = o { Some(path) } else { None }).unwrap()
|
||||
};
|
||||
let bez = |sp: &vectorcraft_geom::SubPath| {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
bp
|
||||
};
|
||||
let mut s = session();
|
||||
let v = view();
|
||||
s.select_tool("curvature", v).unwrap();
|
||||
for (x, y) in [(100.0, 300.0), (130.0, 280.0), (300.0, 360.0)] {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 420.0, 200.0), v).unwrap();
|
||||
let preview = band(&mut s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 200.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 420.0, 200.0), v).unwrap();
|
||||
let id = paths(&s)[0].0;
|
||||
let made = path(&s, id).subpaths[0].clone();
|
||||
assert_eq!(made.anchors.len(), 4);
|
||||
assert_eq!(preview, bez(&made));
|
||||
// Going on from the end of a selected pen path: the band is its last segment and the new one.
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
let anchors = json!([{"x": 100, "y": 500}, {"x": 200, "y": 420, "in": [160, 430], "out": [240, 410]}, {"x": 320, "y": 480, "in": [290, 440]}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 450.0, 400.0), v).unwrap();
|
||||
let preview = band(&mut s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 450.0, 400.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 450.0, 400.0), v).unwrap();
|
||||
let made = path(&s, id).subpaths[0].clone();
|
||||
assert_eq!(made.anchors.len(), 4);
|
||||
let tail = vectorcraft_geom::SubPath::new(made.anchors[1..].to_vec(), false);
|
||||
assert_eq!(preview, bez(&tail));
|
||||
}
|
||||
|
||||
/// Without a handle position, `path.convertAnchor` to smooth curves the point as the Curvature
|
||||
/// tool does: along the circle through it and its neighbours.
|
||||
#[test]
|
||||
fn convert_anchor_to_smooth_uses_the_curvature_rule() {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 200, "y": 100}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("path.convertAnchor", &json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth"})).unwrap();
|
||||
let a = path(&s, id).subpaths[0].anchors[1];
|
||||
assert_eq!(a.kind, vectorcraft_geom::AnchorKind::Smooth);
|
||||
// Along the circle through it and its neighbours, each between a third and two thirds of
|
||||
// its own chord; the shorter side's handle is the shorter.
|
||||
let (prev, next) = (Point::new(0.0, 0.0), Point::new(200.0, 100.0));
|
||||
for (h, to) in [(a.h_in, prev), (a.h_out, next)] {
|
||||
let r = a.p.distance(h) / a.p.distance(to);
|
||||
assert!((1.0 / 3.0 - 1e-9..=2.0 / 3.0 + 1e-9).contains(&r), "{r}");
|
||||
}
|
||||
assert!(a.p.distance(a.h_in) < a.p.distance(a.h_out));
|
||||
let (u_in, u_out) = ((a.p - a.h_in).normalize(), (a.h_out - a.p).normalize());
|
||||
assert!((u_in - u_out).hypot() < 1e-9, "the handles are in line");
|
||||
// Converting back takes the handles off.
|
||||
s.execute("path.convertAnchor", &json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "corner"})).unwrap();
|
||||
assert!(!path(&s, id).subpaths[0].anchors[1].has_out());
|
||||
assert!(s.execute("path.convertAnchor", &json!({"id": id.0, "subpath": 0, "anchor": 9, "to": "smooth"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_clicks_build_smooth_path() {
|
||||
let mut s = session();
|
||||
|
||||
+331
-4
@@ -10,12 +10,12 @@ mod gesture;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
|
||||
|
||||
use crate::{Action, Tool, ToolContext};
|
||||
|
||||
pub use anchor::AnchorTool;
|
||||
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
pub use curvature::{CurvatureTool, EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move, curvature_smooth};
|
||||
pub use family::FamilyTool;
|
||||
pub use gesture::GestureTool;
|
||||
|
||||
@@ -33,9 +33,98 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A handle's length as a fraction of its segment's chord where the handle runs along the chord.
|
||||
/// It grows as the handle turns away from the chord ([`arc_handle`]).
|
||||
pub(crate) const HANDLE_K: f64 = 1.0 / 3.0;
|
||||
|
||||
/// How long a handle leaving along `dir` should be for its segment, `chord`, to follow a circular
|
||||
/// arc: `|chord| / (3 cos²(α/2))`, `α` the angle between the handle and the chord. A third of the
|
||||
/// chord along it, up to two thirds at 90° (the cap: a handle turned further is treated as at 90°).
|
||||
fn arc_handle(chord: Vec2, dir: Vec2) -> f64 {
|
||||
let (l, ld) = (chord.hypot(), dir.hypot());
|
||||
if !(l > 1e-9 && ld > 1e-12) {
|
||||
return 0.0;
|
||||
}
|
||||
// cos²(α/2) = (1 + cos α) / 2.
|
||||
let cos = (dir.dot(chord) / (ld * l)).clamp(0.0, 1.0);
|
||||
2.0 * HANDLE_K * l / (1.0 + cos)
|
||||
}
|
||||
|
||||
/// The sine of half the angle between a smooth point's chords (sin 15°) below which its handles
|
||||
/// shorten: a turn tighter than 30° is cut back toward a corner instead of swinging round the
|
||||
/// circle through the three points, which for a narrow V is a near-whole circle.
|
||||
pub(crate) const TIGHT_TURN: f64 = 0.258_819_045_102_520_8;
|
||||
|
||||
/// The handles `(in, out)` of a smooth point `p` between `prev` and `next`, so the curve through
|
||||
/// the three follows the circle through them: both along that circle's tangent at `p`, the
|
||||
/// direction of `(next − p)/|next − p|² − (prev − p)/|prev − p|²` (the shorter side weighs more),
|
||||
/// each as long as [`arc_handle`] says for its own segment; in a turn tighter than 30°
|
||||
/// ([`TIGHT_TURN`]) shorter, by the square of how far it is under, so a narrow V comes to a point.
|
||||
/// Three points in a line give a straight tangent. None when there is no direction: a chord of no length, or a fold straight back on
|
||||
/// itself. The point is then a corner.
|
||||
pub(crate) fn smooth_handles(prev: Point, p: Point, next: Point) -> Option<(Point, Point)> {
|
||||
let (a, b) = (prev - p, next - p);
|
||||
let (la2, lb2) = (a.hypot2(), b.hypot2());
|
||||
if !(la2.is_finite() && lb2.is_finite()) || la2 <= 1e-18 || lb2 <= 1e-18 {
|
||||
return None;
|
||||
}
|
||||
// In a line with `p` not between its neighbours, the path folds straight back.
|
||||
if a.cross(b).abs() <= 1e-9 * la2.sqrt() * lb2.sqrt() && a.dot(b) > 0.0 {
|
||||
return None;
|
||||
}
|
||||
let t = b / lb2 - a / la2;
|
||||
let lt = t.hypot();
|
||||
if !lt.is_finite() || lt * la2.sqrt().min(lb2.sqrt()) <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let u = t / lt;
|
||||
// sin(φ/2) = √((1 − cos φ) / 2), φ the angle at `p` between the chords.
|
||||
let cos = (a.dot(b) / (la2.sqrt() * lb2.sqrt())).clamp(-1.0, 1.0);
|
||||
let k = (((1.0 - cos) / 2.0).sqrt() / TIGHT_TURN).min(1.0).powi(2);
|
||||
let (h_in, h_out) = (p - u * (k * arc_handle(a, -u)), p + u * (k * arc_handle(b, u)));
|
||||
finite(h_in, h_out)
|
||||
}
|
||||
|
||||
/// The one handle an end `p` of an open path gets so its segment, to the neighbour `q`, follows
|
||||
/// the same circle as the curve at `q`: `q`'s tangent mirrored in the chord, so the end leaves the
|
||||
/// chord at the angle the curve meets `q` at, and as long as [`arc_handle`] says. Where `q`'s own
|
||||
/// handle on their segment falls short of the circle's (a tight turn at `q`), the end handle turns
|
||||
/// toward the chord and shortens in that share, so the end segment straightens too. `forward` when
|
||||
/// `p` comes before `q` on the path (its first anchor), else after it (its last). None when `q`
|
||||
/// has no handle to take a tangent from (a corner): the segment stays straight.
|
||||
pub(crate) fn end_handle(p: Point, q: &Anchor, forward: bool) -> Option<Point> {
|
||||
let t = if q.has_out() {
|
||||
q.h_out - q.p
|
||||
} else if q.has_in() {
|
||||
q.p - q.h_in
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let (t, chord) = (if forward { t } else { -t }, q.p - p);
|
||||
let (lt, lc) = (t.hypot(), chord.hypot());
|
||||
if !(lt > 1e-12 && lc > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let (t, c) = (t / lt, chord / lc);
|
||||
let d = c * (2.0 * t.dot(c)) - t;
|
||||
let back = if forward { q.h_in } else { q.h_out } - q.p;
|
||||
let full = arc_handle(p - q.p, back);
|
||||
let share = if back.hypot() > 1e-9 && full > 1e-9 { (back.hypot() / full).min(1.0) } else { 1.0 };
|
||||
// Short of the circle, the end also turns toward its chord in that share.
|
||||
let d = c * (1.0 - share) + d * share;
|
||||
let d = d / d.hypot().max(1e-12);
|
||||
let h = p + d * (share * arc_handle(chord, d));
|
||||
(h.x.is_finite() && h.y.is_finite()).then_some(h)
|
||||
}
|
||||
|
||||
/// `(a, b)` when every coordinate is finite.
|
||||
fn finite(a: Point, b: Point) -> Option<(Point, Point)> {
|
||||
(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()).then_some((a, b))
|
||||
}
|
||||
|
||||
/// A spline through `points` (Catmull-Rom converted to cubic Béziers). Points flagged `true` are
|
||||
/// corners (no handles); open ends have no handles either. Used by the Curvature tool and by the
|
||||
/// engine's freehand fitting.
|
||||
/// corners (no handles); open ends have no handles either. Used by the freehand fitting (Pencil,
|
||||
/// Paintbrush); the Curvature tool has its own rule, [`curvature_spline`].
|
||||
pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath {
|
||||
let n = points.len();
|
||||
let mut anchors = Vec::with_capacity(n);
|
||||
@@ -58,6 +147,46 @@ pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath {
|
||||
SubPath::new(anchors, closed && n > 2)
|
||||
}
|
||||
|
||||
/// A spline through `points` by the Curvature tool's rule (not a uniform Catmull-Rom: see
|
||||
/// [`catmull_rom`], which the freehand fitting keeps): each stretch of three points follows the
|
||||
/// circle through them. Every smooth point takes [`smooth_handles`] from its neighbours (a corner,
|
||||
/// or one with no direction, gets none); with three or more points the ends of an open path get
|
||||
/// [`end_handle`] into the curve unless flagged `true`, and a closed path has no ends. Points flagged `true` are corners; two points are a straight line. The
|
||||
/// drawing, its preview and the engine's `path.curvature` all build the path with this.
|
||||
pub fn curvature_spline(points: &[(Point, bool)], closed: bool) -> SubPath {
|
||||
let n = points.len();
|
||||
let mut anchors: Vec<Anchor> = Vec::with_capacity(n);
|
||||
for (i, &(p, corner)) in points.iter().enumerate() {
|
||||
let open_end = !closed && (i == 0 || i + 1 == n);
|
||||
let neighbours = (n >= 3 && !corner && !open_end)
|
||||
.then(|| (points.get((i + n - 1) % n), points.get((i + 1) % n)))
|
||||
.and_then(|(prev, next)| Some((prev?.0, next?.0)));
|
||||
anchors.push(match neighbours.and_then(|(prev, next)| smooth_handles(prev, p, next)) {
|
||||
Some((h_in, h_out)) => Anchor { p, h_in, h_out, kind: AnchorKind::Smooth },
|
||||
None => Anchor::corner(p),
|
||||
});
|
||||
}
|
||||
if n >= 3 && !closed {
|
||||
// The end handles mirror the neighbours' tangents, which are in place by now.
|
||||
let (first, last) = (points.first().is_some_and(|f| !f.1), points.last().is_some_and(|l| !l.1));
|
||||
let head = match (anchors.first(), anchors.get(1)) {
|
||||
(Some(a), Some(b)) if first => end_handle(a.p, b, true),
|
||||
_ => None,
|
||||
};
|
||||
let tail = match (anchors.last(), n.checked_sub(2).and_then(|j| anchors.get(j))) {
|
||||
(Some(a), Some(b)) if last => end_handle(a.p, b, false),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(h), Some(a)) = (head, anchors.first_mut()) {
|
||||
a.h_out = h;
|
||||
}
|
||||
if let (Some(h), Some(a)) = (tail, anchors.last_mut()) {
|
||||
a.h_in = h;
|
||||
}
|
||||
}
|
||||
SubPath::new(anchors, closed && n > 2)
|
||||
}
|
||||
|
||||
/// `[[x, y], …]` for command params.
|
||||
pub fn points_json(pts: &[Point]) -> Value {
|
||||
Value::Array(pts.iter().map(|p| json!([p.x, p.y])).collect())
|
||||
@@ -181,6 +310,7 @@ pub(crate) fn remove_anchor((id, si, ai): (NodeId, usize, usize)) -> Action {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
|
||||
#[test]
|
||||
fn catmull_rom_passes_through_points() {
|
||||
@@ -194,6 +324,203 @@ mod tests {
|
||||
assert!(!sp.anchors[0].has_out());
|
||||
}
|
||||
|
||||
/// The freehand fitting (Pencil, Paintbrush) keeps its rule: a symmetric handle of a sixth of
|
||||
/// the chord between the neighbours, corners and open ends without handles.
|
||||
#[test]
|
||||
fn catmull_rom_keeps_the_freehand_rule() {
|
||||
let pts = [(pt(0.0, 0.0), false), (pt(10.0, 0.0), false), (pt(200.0, 100.0), false), (pt(150.0, 160.0), true), (pt(0.0, 200.0), false)];
|
||||
let sp = catmull_rom(&pts, false);
|
||||
let t = (pts[2].0 - pts[0].0) / 6.0;
|
||||
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (pts[1].0 - t, pts[1].0 + t));
|
||||
let t = (pts[3].0 - pts[1].0) / 6.0;
|
||||
assert_eq!((sp.anchors[2].h_in, sp.anchors[2].h_out), (pts[2].0 - t, pts[2].0 + t));
|
||||
for i in [0, 3, 4] {
|
||||
assert_eq!(sp.anchors[i], Anchor::corner(pts[i].0), "anchor {i}");
|
||||
}
|
||||
// Closed: every smooth point wraps round.
|
||||
let t = (pts[1].0 - pts[4].0) / 6.0;
|
||||
let closed = catmull_rom(&pts, true);
|
||||
assert_eq!((closed.anchors[0].h_in, closed.anchors[0].h_out), (pts[0].0 - t, pts[0].0 + t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_points_curve_through_all_of_them() {
|
||||
let pts = [(pt(0.0, 0.0), false), (pt(50.0, 50.0), false), (pt(100.0, 0.0), false)];
|
||||
let sp = curvature_spline(&pts, false);
|
||||
assert_eq!(sp.anchors.len(), 3);
|
||||
assert_eq!((sp.anchors[1].p, sp.anchors[1].kind), (pt(50.0, 50.0), AnchorKind::Smooth));
|
||||
// The middle tangent is horizontal.
|
||||
assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9);
|
||||
// Both ends get one handle into the curve, none on the outer side.
|
||||
let (first, last) = (sp.anchors[0], sp.anchors[2]);
|
||||
assert!(first.has_out() && !first.has_in() && last.has_in() && !last.has_out());
|
||||
assert!(!sp.segment_is_line(0) && !sp.segment_is_line(1));
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
fn smooth(points: &[(f64, f64)]) -> Vec<(Point, bool)> {
|
||||
points.iter().map(|&(x, y)| (pt(x, y), false)).collect()
|
||||
}
|
||||
|
||||
fn finite(sp: &SubPath) -> bool {
|
||||
sp.anchors.iter().all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite()))
|
||||
}
|
||||
|
||||
/// The point at `deg` degrees on the circle of radius `r` round `c`.
|
||||
fn on_circle(c: Point, r: f64, deg: f64) -> Point {
|
||||
let a = deg.to_radians();
|
||||
Point::new(c.x + r * a.cos(), c.y + r * a.sin())
|
||||
}
|
||||
|
||||
/// The largest relative error of the curve's distance from `c` against `r`, sampled.
|
||||
fn off_circle(sp: &SubPath, c: Point, r: f64) -> f64 {
|
||||
(0..sp.segment_count())
|
||||
.flat_map(|i| (0..=50).map(move |k| (i, f64::from(k) / 50.0)))
|
||||
.map(|(i, t)| (sp.segment(i).eval(t).distance(c) - r).abs() / r)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// The angle between two directions, in degrees.
|
||||
fn angle(a: Vec2, b: Vec2) -> f64 {
|
||||
a.cross(b).abs().atan2(a.dot(b)).to_degrees()
|
||||
}
|
||||
|
||||
/// Three points on a circle give a curve that stays on it, the ends included, however they
|
||||
/// are spaced along it (a J or a U as much as an even arc).
|
||||
#[test]
|
||||
fn three_points_on_a_circle_follow_it() {
|
||||
let c = pt(300.0, 300.0);
|
||||
for degs in [[210.0, 270.0, 330.0], [180.0, 230.0, 340.0], [150.0, 270.0, 30.0], [100.0, 180.0, 280.0]] {
|
||||
let pts: Vec<(Point, bool)> = degs.iter().map(|&d| (on_circle(c, 100.0, d), false)).collect();
|
||||
let sp = curvature_spline(&pts, false);
|
||||
let e = off_circle(&sp, c, 100.0);
|
||||
assert!(e < 0.01, "{degs:?}: {:.3}% off the circle", e * 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Three points: both ends get a handle, leaving the chord at the angle the curve meets the
|
||||
/// middle point at, measured from the same chord.
|
||||
#[test]
|
||||
fn the_ends_leave_their_chord_as_the_curve_meets_the_middle() {
|
||||
let pts = smooth(&[(100.0, 100.0), (130.0, 400.0), (420.0, 430.0)]);
|
||||
let sp = curvature_spline(&pts, false);
|
||||
let (first, mid, last) = (sp.anchors[0], sp.anchors[1], sp.anchors[2]);
|
||||
assert!(first.has_out() && last.has_in());
|
||||
let t = mid.h_out - mid.p;
|
||||
let c = mid.p - first.p;
|
||||
assert!((angle(first.h_out - first.p, c) - angle(t, c)).abs() < 1e-9);
|
||||
let c = last.p - mid.p;
|
||||
assert!((angle(last.p - last.h_in, c) - angle(t, c)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The middle tangent is the circle's: for points symmetric about a vertical axis it is
|
||||
/// horizontal, and the handle on the shorter side is the shorter.
|
||||
#[test]
|
||||
fn the_tangent_is_the_circles() {
|
||||
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (50.0, 80.0), (100.0, 0.0)]), false);
|
||||
let m = sp.anchors[1];
|
||||
assert!((m.h_out.y - m.p.y).abs() < 1e-9 && (m.h_in.y - m.p.y).abs() < 1e-9);
|
||||
assert!(m.h_out.x > m.p.x && m.h_in.x < m.p.x);
|
||||
let (prev, p, next) = (pt(0.0, 0.0), pt(10.0, 0.0), pt(200.0, 100.0));
|
||||
let (h_in, h_out) = smooth_handles(prev, p, next).unwrap();
|
||||
assert!(p.distance(h_in) < p.distance(h_out));
|
||||
assert!((h_in - p).cross(h_out - p).abs() < 1e-9 && (h_in - p).dot(h_out - p) < 0.0, "in line, on either side");
|
||||
// A handle along its chord is a third of it; turned 90° from it, two thirds.
|
||||
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(1.0, 0.0)) - 10.0).abs() < 1e-12);
|
||||
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(0.0, 1.0)) - 20.0).abs() < 1e-12);
|
||||
assert!((arc_handle(Vec2::new(30.0, 0.0), Vec2::new(-1.0, 0.1)) - 20.0).abs() < 1e-12, "capped at 90°");
|
||||
}
|
||||
|
||||
/// A narrow V comes to a point at its foot instead of swinging round the circle through its
|
||||
/// three points, ends included; a turn of 30° or more keeps the circle's handles.
|
||||
#[test]
|
||||
fn a_narrow_v_comes_to_a_point() {
|
||||
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (35.0, 800.0), (70.0, 10.0)]), false);
|
||||
for seg in 0..2 {
|
||||
for k in 0..=40 {
|
||||
let x = sp.segment(seg).eval(f64::from(k) / 40.0).x;
|
||||
assert!((-3.0..=73.0).contains(&x), "segment {seg} reaches x = {x}");
|
||||
}
|
||||
}
|
||||
let m = sp.anchors[1];
|
||||
assert!(m.p.distance(m.h_out) < 0.1 * m.p.distance(pt(70.0, 10.0)));
|
||||
// A U whose chords meet at about 45° still follows its circle.
|
||||
let c = pt(300.0, 300.0);
|
||||
let pts: Vec<(Point, bool)> = [225.0, 90.0, 315.0].iter().map(|&d| (on_circle(c, 100.0, d), false)).collect();
|
||||
assert!(off_circle(&curvature_spline(&pts, false), c, 100.0) < 0.01);
|
||||
}
|
||||
|
||||
/// Two line segments `(a, b)` and `(c, d)` cross.
|
||||
fn crosses(a: Point, b: Point, c: Point, d: Point) -> bool {
|
||||
let side = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
|
||||
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
|
||||
d1 * d2 < 0.0 && d3 * d4 < 0.0
|
||||
}
|
||||
|
||||
/// Very uneven spacing: no NaN, and the curve never crosses itself.
|
||||
#[test]
|
||||
fn very_uneven_spacing_stays_finite_and_simple() {
|
||||
for pts in [
|
||||
vec![(0.0, 0.0), (1.0, 0.0), (1000.0, 500.0), (1001.0, 520.0), (1500.0, 0.0)],
|
||||
vec![(0.0, 0.0), (800.0, 10.0), (802.0, 400.0), (0.5, 410.0)],
|
||||
vec![(0.0, 0.0), (0.001, 0.001), (300.0, 0.0), (300.0, 900.0), (299.0, 901.0)],
|
||||
] {
|
||||
let sp = curvature_spline(&smooth(&pts), false);
|
||||
assert!(finite(&sp), "{pts:?}");
|
||||
let poly: Vec<Point> =
|
||||
(0..sp.segment_count()).flat_map(|i| (0..30).map(move |k| (i, f64::from(k) / 30.0))).map(|(i, t)| sp.segment(i).eval(t)).collect();
|
||||
for i in 0..poly.len() - 1 {
|
||||
for j in i + 2..poly.len() - 1 {
|
||||
assert!(!crosses(poly[i], poly[i + 1], poly[j], poly[j + 1]), "{pts:?} crosses itself near {:?}", poly[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_points_are_a_line_and_corners_have_no_handles() {
|
||||
let sp = curvature_spline(&smooth(&[(0.0, 0.0), (80.0, 40.0)]), false);
|
||||
assert!(sp.anchors.iter().all(|a| !a.has_in() && !a.has_out()) && sp.segment_is_line(0));
|
||||
// An Alt-clicked middle point is a corner; its neighbours keep their end handle only where
|
||||
// the neighbour has a handle on that segment, so both segments here stay straight.
|
||||
let pts = [(pt(0.0, 0.0), false), (pt(50.0, 50.0), true), (pt(100.0, 0.0), false)];
|
||||
let sp = curvature_spline(&pts, false);
|
||||
assert!(!sp.anchors[1].has_in() && !sp.anchors[1].has_out());
|
||||
assert!(sp.segment_is_line(0) && sp.segment_is_line(1));
|
||||
// An Alt-clicked end stays a corner while the other end curves in.
|
||||
let pts = [(pt(0.0, 0.0), true), (pt(50.0, 50.0), false), (pt(100.0, 0.0), false), (pt(150.0, 50.0), false)];
|
||||
let sp = curvature_spline(&pts, false);
|
||||
assert!(!sp.anchors[0].has_out() && sp.anchors[3].has_in());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_path_is_smooth_all_round() {
|
||||
let pts = smooth(&[(0.0, 0.0), (100.0, 10.0), (120.0, 90.0), (10.0, 100.0)]);
|
||||
let sp = curvature_spline(&pts, true);
|
||||
assert!(sp.closed);
|
||||
for a in &sp.anchors {
|
||||
assert_eq!(a.kind, AnchorKind::Smooth);
|
||||
assert!((a.h_in - a.p).cross(a.h_out - a.p).abs() < 1e-9 * a.p.distance(a.h_out).max(1.0) * a.p.distance(a.h_in).max(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_points_become_corners_without_nan() {
|
||||
// A repeated point, and a fold straight back on itself.
|
||||
let dup = curvature_spline(&smooth(&[(0.0, 0.0), (50.0, 0.0), (50.0, 0.0), (100.0, 40.0)]), false);
|
||||
let fold = curvature_spline(&smooth(&[(0.0, 0.0), (100.0, 0.0), (0.0, 0.0)]), false);
|
||||
let same = curvature_spline(&smooth(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)]), true);
|
||||
for sp in [&dup, &fold, &same] {
|
||||
assert!(finite(sp));
|
||||
}
|
||||
assert_eq!(fold.anchors[1].kind, AnchorKind::Corner);
|
||||
assert!(!fold.anchors[1].has_in() && !fold.anchors[1].has_out());
|
||||
assert!(smooth_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(1.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_exist() {
|
||||
for id in [
|
||||
|
||||
@@ -20,7 +20,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, Selection};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath, Vec2};
|
||||
|
||||
use super::{FEEDBACK, anchor_in, catmull_rom, editable_paths, remove_anchor, segment_in};
|
||||
use super::{FEEDBACK, anchor_in, curvature_spline, editable_paths, end_handle, remove_anchor, segment_in, smooth_handles};
|
||||
use crate::guides::{DrawSnap, Leave, Targets};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -92,8 +92,8 @@ pub enum EndCurve {
|
||||
/// curve where it has none, and with both it is smooth.
|
||||
#[default]
|
||||
Keep,
|
||||
/// Smooth through its neighbours, as [`catmull_rom`] curves a point (a point the Curvature tool
|
||||
/// placed: its segment re-curves).
|
||||
/// Smooth through its neighbours, as [`curvature_spline`] curves a point (a point the Curvature
|
||||
/// tool placed: its segment re-curves).
|
||||
Smooth,
|
||||
/// A corner, without handles.
|
||||
Corner,
|
||||
@@ -127,14 +127,21 @@ fn neighbours(sp: &SubPath, i: usize) -> [Option<usize>; 2] {
|
||||
}
|
||||
|
||||
/// Move anchor `i` of `sp` to `q` as the Curvature tool drags a point: only the two segments at it
|
||||
/// change. A smooth anchor turns along the line through its neighbours; the neighbours' handles on
|
||||
/// those segments, and a corner's own handles, keep their directions, their lengths scaling with
|
||||
/// their segment's chord, so the segments beyond don't move and a corner without handles keeps
|
||||
/// none. False when there is no anchor `i`.
|
||||
/// change. A smooth anchor takes the Curvature tool's own handles ([`smooth_handles`]: along the
|
||||
/// circle through it and its neighbours; at an end of an open path the one [`end_handle`] into the
|
||||
/// curve). The neighbours' handles on those segments, and a corner's own
|
||||
/// handles, keep their directions, their lengths scaling with their segment's chord, so the
|
||||
/// segments beyond don't move and a corner without handles keeps none.
|
||||
///
|
||||
/// The neighbours' tangents are not re-curved: a selected path may come from the Pen or any other
|
||||
/// tool, and turning its smooth points would reshape what the user drew. The path the tool is
|
||||
/// drawing ([`Grab::Drawn`]) is rebuilt by [`curvature_spline`] from its points, so there the
|
||||
/// neighbours do turn with the dragged point. False when there is no anchor `i`.
|
||||
pub fn curvature_move(sp: &mut SubPath, i: usize, q: Point) -> bool {
|
||||
let Some(&a) = sp.anchors.get(i) else { return false };
|
||||
let mut own = [a.h_in - a.p, a.h_out - a.p];
|
||||
let mut ends = [None; 2];
|
||||
// Each neighbour, its handle on the segment to `a` scaled.
|
||||
let mut ends: [Option<Anchor>; 2] = [None; 2];
|
||||
for (side, j) in neighbours(sp, i).into_iter().enumerate() {
|
||||
let Some(b) = j.and_then(|j| sp.anchors.get_mut(j)) else { continue };
|
||||
let old = a.p.distance(b.p);
|
||||
@@ -142,17 +149,21 @@ pub fn curvature_move(sp: &mut SubPath, i: usize, q: Point) -> bool {
|
||||
let h = if side == 0 { &mut b.h_out } else { &mut b.h_in };
|
||||
*h = b.p + (*h - b.p) * s;
|
||||
own[side] *= s;
|
||||
ends[side] = Some(b.p);
|
||||
ends[side] = Some(*b);
|
||||
}
|
||||
let mut m = Anchor { p: q, h_in: q + own[0], h_out: q + own[1], kind: a.kind };
|
||||
if let (AnchorKind::Smooth, [Some(prev), Some(next)]) = (a.kind, ends) {
|
||||
let d = next - prev;
|
||||
let l = d.hypot();
|
||||
if l > 1e-9 {
|
||||
// A handle it lacks comes out a third of the way to that side's neighbour.
|
||||
let len = |h: Vec2, to: Point| if h.hypot() > 1e-9 { h.hypot() } else { q.distance(to) / 3.0 };
|
||||
m.h_in = q - d * (len(own[0], prev) / l);
|
||||
m.h_out = q + d * (len(own[1], next) / l);
|
||||
if a.kind == AnchorKind::Smooth {
|
||||
match ends {
|
||||
[Some(prev), Some(next)] => {
|
||||
if let Some((h_in, h_out)) = smooth_handles(prev.p, q, next.p) {
|
||||
(m.h_in, m.h_out) = (h_in, h_out);
|
||||
}
|
||||
}
|
||||
// An end of an open path has one segment: its handle only, and a corner like the
|
||||
// ends `curvature_spline` makes.
|
||||
[None, Some(next)] => m = Anchor { h_out: end_handle(q, &next, true).unwrap_or(q), h_in: q, kind: AnchorKind::Corner, ..m },
|
||||
[Some(prev), None] => m = Anchor { h_in: end_handle(q, &prev, false).unwrap_or(q), h_out: q, kind: AnchorKind::Corner, ..m },
|
||||
[None, None] => {}
|
||||
}
|
||||
}
|
||||
if let Some(x) = sp.anchors.get_mut(i) {
|
||||
@@ -161,9 +172,10 @@ pub fn curvature_move(sp: &mut SubPath, i: usize, q: Point) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add a smooth anchor at `t` on segment `seg` of `sp` without changing its shape (on a straight
|
||||
/// segment its handles lie along it, so a drag curves it): the new anchor's index. None when there
|
||||
/// is no such segment.
|
||||
/// Add a smooth anchor at `t` on segment `seg` of `sp` without changing its shape: the new anchor's
|
||||
/// index. On a straight segment its handles lie along it, each a third of its own part of the
|
||||
/// segment ([`curvature_smooth`]: a line is a circle's limit), so a drag bends the line evenly; a drag then moves it as a
|
||||
/// smooth point ([`curvature_move`]). None when there is no such segment.
|
||||
pub fn curvature_insert(sp: &mut SubPath, seg: usize, t: f64) -> Option<usize> {
|
||||
if seg >= sp.segment_count() {
|
||||
return None;
|
||||
@@ -171,19 +183,49 @@ pub fn curvature_insert(sp: &mut SubPath, seg: usize, t: f64) -> Option<usize> {
|
||||
let line = sp.segment_is_line(seg);
|
||||
let i = sp.insert_anchor(seg, if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.5 });
|
||||
if line {
|
||||
sp.smooth_anchor(i);
|
||||
curvature_smooth(sp, i);
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`, which
|
||||
/// has no handles; the old end bends into the new segment as `how` says.
|
||||
pub fn curvature_extend(sp: &mut SubPath, start: bool, p: Point, how: EndCurve) {
|
||||
/// Make anchor `i` of `sp` smooth as the Curvature tool curves a point: [`smooth_handles`] from its
|
||||
/// neighbours, or at an end of an open path the one [`end_handle`] into the curve (no outer handle;
|
||||
/// it stays a corner, as the ends [`curvature_spline`] makes). False, and `sp` unchanged, when
|
||||
/// there is no anchor `i` or no direction: the neighbours coincide with it or fold back on it, or
|
||||
/// an end's neighbour has no handle to take a tangent from.
|
||||
pub fn curvature_smooth(sp: &mut SubPath, i: usize) -> bool {
|
||||
let at = |j: Option<usize>| j.and_then(|j| sp.anchors.get(j)).copied();
|
||||
let [prev, next] = neighbours(sp, i).map(at);
|
||||
let Some(a) = sp.anchors.get(i).copied() else { return false };
|
||||
let m = match (prev, next) {
|
||||
(Some(b), Some(c)) => smooth_handles(b.p, a.p, c.p).map(|(h_in, h_out)| Anchor { p: a.p, h_in, h_out, kind: AnchorKind::Smooth }),
|
||||
(None, Some(c)) => end_handle(a.p, &c, true).map(|h_out| Anchor { h_in: a.p, h_out, ..Anchor::corner(a.p) }),
|
||||
(Some(b), None) => end_handle(a.p, &b, false).map(|h_in| Anchor { h_in, h_out: a.p, ..Anchor::corner(a.p) }),
|
||||
(None, None) => None,
|
||||
};
|
||||
match (m, sp.anchors.get_mut(i)) {
|
||||
(Some(m), Some(x)) => {
|
||||
*x = m;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Go on from an end of the open subpath `sp` (its start with `start`) to a new end at `p`; the old
|
||||
/// end bends into the new segment as `how` says. The new end has no handle on the outer side and,
|
||||
/// unless it is a `corner` (Alt-clicked), the one [`end_handle`] into the curve, so its segment
|
||||
/// curves as the last one of a path drawn from scratch does.
|
||||
pub fn curvature_extend(sp: &mut SubPath, start: bool, p: Point, how: EndCurve, corner: bool) {
|
||||
if start {
|
||||
sp.reverse();
|
||||
}
|
||||
bend_last(sp, p, how);
|
||||
sp.anchors.push(Anchor::corner(p));
|
||||
let mut end = Anchor::corner(p);
|
||||
if let (false, Some(old)) = (corner, sp.anchors.last()) {
|
||||
end.h_in = end_handle(p, old, false).unwrap_or(p);
|
||||
}
|
||||
sp.anchors.push(end);
|
||||
if start {
|
||||
sp.reverse();
|
||||
}
|
||||
@@ -213,14 +255,12 @@ fn bend_last(sp: &mut SubPath, to: Point, how: EndCurve) {
|
||||
let Some(e) = sp.anchors.last_mut() else { return };
|
||||
match how {
|
||||
EndCurve::Corner => e.retract(),
|
||||
EndCurve::Smooth => {
|
||||
let t = prev.map_or(Vec2::ZERO, |b| (to - b.p) / 6.0);
|
||||
if t.hypot() > 1e-9 {
|
||||
*e = Anchor { p: e.p, h_in: e.p - t, h_out: e.p + t, kind: AnchorKind::Smooth };
|
||||
} else {
|
||||
e.retract();
|
||||
}
|
||||
}
|
||||
EndCurve::Smooth => match prev.and_then(|b| smooth_handles(b.p, e.p, to)) {
|
||||
Some((h_in, h_out)) => *e = Anchor { p: e.p, h_in, h_out, kind: AnchorKind::Smooth },
|
||||
// Nothing before it, or no direction: the new point has no handle yet for an end
|
||||
// handle to aim at, so the segment stays straight (two points are a line).
|
||||
None => e.retract(),
|
||||
},
|
||||
EndCurve::Keep => {
|
||||
// The way the curve comes in: from its handle, or else the segment's tangent there.
|
||||
let back = if e.has_in() { Some(e.h_in) } else { prev.map(|b| if b.h_out.distance(e.p) > 1e-9 { b.h_out } else { b.p }) };
|
||||
@@ -273,10 +313,15 @@ fn select_point((id, si, ai): (NodeId, usize, usize)) -> Action {
|
||||
Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))
|
||||
}
|
||||
|
||||
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner.
|
||||
/// Toggle anchor `(id, si, ai)` of a selected path between smooth and corner. An end of an open
|
||||
/// path counts as smooth while it has a handle into its segment (the ends the tool curves are
|
||||
/// corners with one handle).
|
||||
fn toggle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize)) -> Option<Action> {
|
||||
let a = subpath(cx, id, si)?.anchors.get(ai)?;
|
||||
let to = if a.kind == AnchorKind::Smooth { "corner" } else { "smooth" };
|
||||
let sp = subpath(cx, id, si)?;
|
||||
let a = sp.anchors.get(ai)?;
|
||||
let open_end = !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len());
|
||||
let smooth = if open_end { a.has_in() || a.has_out() } else { a.kind == AnchorKind::Smooth };
|
||||
let to = if smooth { "corner" } else { "smooth" };
|
||||
Some(Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": to})))
|
||||
}
|
||||
|
||||
@@ -378,7 +423,7 @@ impl CurvatureTool {
|
||||
let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false)));
|
||||
let how = self.how(&e);
|
||||
self.placed = Some(Placed { id: e.id, si: e.si, start: e.start, anchors: e.anchors + 1, corner: ev.mods.alt });
|
||||
let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name()});
|
||||
let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name(), "corner": ev.mods.alt});
|
||||
return Some(vec![Action::Exec(EDIT.into(), v)]);
|
||||
}
|
||||
let (id, si, seg, t) = segment_in(cx, editable_paths(cx), p, tol)?;
|
||||
@@ -550,7 +595,7 @@ impl Tool for CurvatureTool {
|
||||
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
|
||||
};
|
||||
let mut tail = SubPath::new(near, false);
|
||||
curvature_extend(&mut tail, false, h, self.how(&e));
|
||||
curvature_extend(&mut tail, false, h, self.how(&e), false);
|
||||
tail.to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
}
|
||||
@@ -562,7 +607,7 @@ impl Tool for CurvatureTool {
|
||||
{
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push((h, false));
|
||||
catmull_rom(&pts, false).to_bezpath_into(&mut bp);
|
||||
curvature_spline(&pts, false).to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
}
|
||||
for (i, (p, _)) in self.pts.iter().enumerate() {
|
||||
@@ -689,11 +734,17 @@ mod tests {
|
||||
assert!(collinear(sp.anchors[1].h_out - sp.anchors[1].p, before.anchors[1].h_out - before.anchors[1].p));
|
||||
assert!(collinear(sp.anchors[3].h_in - sp.anchors[3].p, before.anchors[3].h_in - before.anchors[3].p));
|
||||
assert_eq!((sp.anchors[1].h_in, sp.anchors[3].h_out), (before.anchors[1].h_in, before.anchors[3].h_out));
|
||||
// The moved smooth point turns along the line through its neighbours.
|
||||
// The moved smooth point takes the Curvature tool's handles, along the circle through it
|
||||
// and its neighbours, whatever the spacing: between a third and two thirds of its chord.
|
||||
let a = sp.anchors[2];
|
||||
let (prev, next) = (sp.anchors[1].p, sp.anchors[3].p);
|
||||
assert_eq!(a.kind, AnchorKind::Smooth);
|
||||
assert!(collinear(a.h_out - a.p, sp.anchors[3].p - sp.anchors[1].p) && collinear(a.h_in - a.p, a.h_out - a.p));
|
||||
assert!((a.h_out - a.p).dot(sp.anchors[3].p - sp.anchors[1].p) > 0.0);
|
||||
assert_eq!(Some((a.h_in, a.h_out)), smooth_handles(prev, a.p, next));
|
||||
assert!(collinear(a.h_in - a.p, a.h_out - a.p) && (a.h_in - a.p).dot(a.h_out - a.p) < 0.0);
|
||||
for (h, to) in [(a.h_in, prev), (a.h_out, next)] {
|
||||
let r = a.p.distance(h) / a.p.distance(to);
|
||||
assert!((1.0 / 3.0 - 1e-9..=2.0 / 3.0 + 1e-9).contains(&r), "{r}");
|
||||
}
|
||||
// A cusp keeps its handle directions; a moved end changes its one segment only.
|
||||
assert_eq!(sp.anchors[3].kind, AnchorKind::Corner);
|
||||
assert!(curvature_move(&mut sp, 3, pt(360.0, 170.0)));
|
||||
@@ -704,6 +755,21 @@ mod tests {
|
||||
assert!(!curvature_move(&mut end, 5, pt(0.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A smooth end of an open path has one segment: its one handle into the curve, mirroring the
|
||||
/// neighbour's tangent in their chord, and nothing on the outer side.
|
||||
#[test]
|
||||
fn moving_a_smooth_end_gives_it_the_end_handle() {
|
||||
let mut sp = curvature_spline(&[(pt(0.0, 0.0), false), (pt(100.0, 60.0), false), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false);
|
||||
sp.anchors[0].kind = AnchorKind::Smooth;
|
||||
let before = sp.clone();
|
||||
assert!(curvature_move(&mut sp, 0, pt(-20.0, 30.0)));
|
||||
let (e, b) = (sp.anchors[0], sp.anchors[1]);
|
||||
assert!(!e.has_in() && e.has_out());
|
||||
assert_eq!(Some(e.h_out), end_handle(e.p, &b, true));
|
||||
assert_eq!(sp.segment(2), before.segment(2));
|
||||
assert_eq!(sp.anchors[2], before.anchors[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_corner_without_handles_keeps_its_sides_straight() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -717,7 +783,7 @@ mod tests {
|
||||
fn going_on_from_an_end_keeps_the_old_segments() {
|
||||
let before = wave();
|
||||
let mut sp = before.clone();
|
||||
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
|
||||
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false);
|
||||
assert_eq!(sp.anchors.len(), 6);
|
||||
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
|
||||
// The old end turns smooth, going on along its incoming handle, a third of the chord out.
|
||||
@@ -725,19 +791,22 @@ mod tests {
|
||||
assert_eq!(e.kind, AnchorKind::Smooth);
|
||||
assert!(collinear(e.h_out - e.p, e.p - e.h_in) && (e.h_out - e.p).dot(e.p - e.h_in) > 0.0);
|
||||
assert!((e.h_out.distance(e.p) - e.p.distance(pt(550.0, 120.0)) / 3.0).abs() < 1e-9);
|
||||
assert_eq!(sp.anchors[5], Anchor::corner(pt(550.0, 120.0)));
|
||||
// The new end has no outer handle and one into the curve, mirroring the old end's tangent.
|
||||
let n = sp.anchors[5];
|
||||
assert_eq!((n.p, n.has_out(), n.kind), (pt(550.0, 120.0), false, AnchorKind::Corner));
|
||||
assert_eq!(Some(n.h_in), end_handle(n.p, &e, false));
|
||||
// The point placed then curves through its neighbours as a drawn path's points do.
|
||||
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
|
||||
let drawn = catmull_rom(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false);
|
||||
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false);
|
||||
let drawn = curvature_spline(&[(pt(450.0, 60.0), false), (pt(550.0, 120.0), false), (pt(650.0, 60.0), false)], false);
|
||||
assert_eq!((sp.anchors[5].h_in, sp.anchors[5].h_out), (drawn.anchors[1].h_in, drawn.anchors[1].h_out));
|
||||
assert!((0..4).all(|i| sp.segment(i) == before.segment(i)));
|
||||
// A corner stays one.
|
||||
curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner);
|
||||
curvature_extend(&mut sp, false, pt(700.0, 100.0), EndCurve::Corner, false);
|
||||
assert!(!sp.anchors[6].has_in() && !sp.anchors[6].has_out());
|
||||
// From the start: the old segments come one later, and the new end is the first anchor.
|
||||
let mut sp = before.clone();
|
||||
curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep);
|
||||
assert_eq!(sp.anchors[0], Anchor::corner(pt(-100.0, 50.0)));
|
||||
curvature_extend(&mut sp, true, pt(-100.0, 50.0), EndCurve::Keep, false);
|
||||
assert!(sp.anchors[0].p == pt(-100.0, 50.0) && !sp.anchors[0].has_in() && sp.anchors[0].has_out());
|
||||
assert!((0..4).all(|i| sp.segment(i + 1) == before.segment(i)));
|
||||
// A corner end without handles goes on along its segment's tangent.
|
||||
let old = sp.anchors[1];
|
||||
@@ -776,6 +845,56 @@ mod tests {
|
||||
assert_eq!(curvature_insert(&mut sp, 99, 0.5), None);
|
||||
}
|
||||
|
||||
/// On a straight side the inserted point's handles lie along it, each a third of its own part
|
||||
/// of the side; a drag then moves it as a smooth Curvature point.
|
||||
#[test]
|
||||
fn a_point_inserted_on_a_line_takes_the_chord_rule_and_drags_smooth() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let rect = d.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
|
||||
let mut sp = rect.clone();
|
||||
let i = curvature_insert(&mut sp, 0, 0.25).unwrap();
|
||||
let (a, prev, next) = (sp.anchors[i], sp.anchors[i - 1].p, sp.anchors[i + 1].p);
|
||||
assert!((a.p.distance(a.h_in) - a.p.distance(prev) / 3.0).abs() < 1e-9);
|
||||
assert!((a.p.distance(a.h_out) - a.p.distance(next) / 3.0).abs() < 1e-9);
|
||||
assert!(collinear(a.h_out - a.p, next - prev) && (a.h_out - a.p).dot(next - prev) > 0.0);
|
||||
assert!(sp.segment_is_line(0) || lies_on(&sp, &rect));
|
||||
// A drag bends it as a smooth point; the sides beyond stay straight.
|
||||
let q = pt(a.p.x, a.p.y - 40.0);
|
||||
assert!(curvature_move(&mut sp, i, q));
|
||||
let m = sp.anchors[i];
|
||||
assert_eq!(Some((m.h_in, m.h_out)), smooth_handles(prev, q, next));
|
||||
assert!((2..sp.segment_count()).all(|s| sp.segment_is_line(s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_smooth_uses_the_tool_rule() {
|
||||
let mut sp = curvature_spline(&[(pt(0.0, 0.0), true), (pt(100.0, 60.0), true), (pt(200.0, 0.0), false), (pt(300.0, 50.0), false)], false);
|
||||
// A corner in the middle takes the bisector handles.
|
||||
assert!(curvature_smooth(&mut sp, 1));
|
||||
let a = sp.anchors[1];
|
||||
assert_eq!((a.kind, Some((a.h_in, a.h_out))), (AnchorKind::Smooth, smooth_handles(pt(0.0, 0.0), a.p, pt(200.0, 0.0))));
|
||||
// An end curves into its segment, mirroring its neighbour's tangent.
|
||||
assert!(curvature_smooth(&mut sp, 0));
|
||||
let e = sp.anchors[0];
|
||||
assert!(!e.has_in() && Some(e.h_out) == end_handle(e.p, &sp.anchors[1], true));
|
||||
// No direction: a fold, or an end whose neighbour has no handle there, stays as it is.
|
||||
let mut fold = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), Anchor::corner(pt(100.0, 0.0)), Anchor::corner(pt(0.0, 0.0))], false);
|
||||
let before = fold.clone();
|
||||
assert!(!curvature_smooth(&mut fold, 1) && !curvature_smooth(&mut fold, 0) && !curvature_smooth(&mut fold, 9));
|
||||
assert_eq!(fold, before);
|
||||
}
|
||||
|
||||
/// A curved end counts as smooth for the toggle: Alt-click turns it into a corner.
|
||||
#[test]
|
||||
fn alt_click_on_a_curved_end_makes_it_a_corner() {
|
||||
let sp = curvature_spline(&[(pt(100.0, 300.0), false), (pt(200.0, 200.0), false), (pt(300.0, 300.0), false)], false);
|
||||
let (d, id) = doc_with(sp);
|
||||
let (s, p) = (selected(id, None), paint());
|
||||
let cx = quiet(&d, &s, &p);
|
||||
let a = press(&mut CurvatureTool::default(), &cx, 100.0, 300.0, true);
|
||||
assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "path.convertAnchor" && v["anchor"] == 0 && v["to"] == "corner")), "{a:?}");
|
||||
}
|
||||
|
||||
/// A document with one path, `sp`.
|
||||
fn doc_with(sp: SubPath) -> (Document, NodeId) {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
@@ -886,7 +1005,7 @@ mod tests {
|
||||
let a = press(&mut t, &quiet(&d, &s, &p), 550.0, 120.0, false);
|
||||
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "end" && v["from"] == "keep" && v["x"] == 550.0), "{a:?}");
|
||||
// As the engine does it: the new end is current, and the next click curves through it.
|
||||
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep);
|
||||
curvature_extend(&mut sp, false, pt(550.0, 120.0), EndCurve::Keep, false);
|
||||
let (d, id) = doc_with(sp.clone());
|
||||
let s = selected(id, Some(5));
|
||||
let cx = quiet(&d, &s, &p);
|
||||
@@ -894,7 +1013,7 @@ mod tests {
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the rubber band shows");
|
||||
let a = press(&mut t, &cx, 650.0, 60.0, true);
|
||||
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["from"] == "smooth"), "{a:?}");
|
||||
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth);
|
||||
curvature_extend(&mut sp, false, pt(650.0, 60.0), EndCurve::Smooth, false);
|
||||
let (d, id) = doc_with(sp.clone());
|
||||
let s = selected(id, Some(6));
|
||||
let cx = quiet(&d, &s, &p);
|
||||
|
||||
+7
-1
File diff suppressed because one or more lines are too long
+3
-3
@@ -1615,11 +1615,11 @@ the path it had begun while that stays selected. Its reply's `tool` is the tool
|
||||
The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798). Each edit
|
||||
is one undo step and leaves the anchor it edited direct-selected (the tool's current point):
|
||||
`path.curvatureEdit {id, subpath?, op, …}` answers `{anchor}`. `op: "move"` with `anchor`, `x`, `y` moves a point and
|
||||
re-curves only its two segments: a smooth point turns along the line through its neighbours, and the neighbours'
|
||||
handles keep their directions (their lengths scale with the chord), so the segments beyond don't change.
|
||||
re-curves only its two segments: a smooth point takes the tool's handles (along the circle through it and its neighbours; the one handle into
|
||||
the curve at an end), and the neighbours' handles keep their directions (their lengths scale with the chord), so the segments beyond don't change.
|
||||
`op: "insert"` with `segment`, `t` adds a smooth point there without changing the shape (with `x`, `y` it is then
|
||||
moved there: the tool's click-and-drag on a segment). `op: "extend"` with `x`, `y` goes on from the `end` (default)
|
||||
or the `start` of an open subpath, and `op: "close"` closes it from that end; `from` says how the old end bends into
|
||||
or the `start` of an open subpath (the new end curves into the path unless `corner` is true), and `op: "close"` closes it from that end; `from` says how the old end bends into
|
||||
the new segment: `"keep"` (default) keeps its curve, `"smooth"` curves it through its neighbours (a point the tool
|
||||
placed), `"corner"` leaves it without handles. Alt-click or double-click toggles a point with `path.convertAnchor`, and
|
||||
Backspace/Delete removes the current point with `path.removeAnchor`.
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -52,7 +52,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
|
||||
| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified |
|
||||
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); existing-path interpolation request and modifiers requested in #975 remain |
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
|
||||
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | |
|
||||
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | partial | Alt to Smooth is missing; with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
|
||||
| Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533) |
|
||||
@@ -224,6 +224,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Curvature: the Curvature tool's curve: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. |
|
||||
| 2026-10-14 | minor | The macOS save sheet's Format menu goes back to missing: #1855 withdrew it to restore saving (#866 reopened). Rows added for the X/Y/W/H of direct-selected anchors (#1839, fixes #1824), the toolbar and All Tools drawer (#1751) and the Actions panel selection (#1853, part of #1629); the snapping row notes Snap to Grid over Snap to Pixel (#1840). Scores unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Width-point finite-arithmetic follow-up: atomicity, history and native round-trip regressions and current full CI passed; scores and hours unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user