From ad3531d46474ac8719980816afe73a8515774e8f Mon Sep 17 00:00:00 2001 From: ham9zah Date: Wed, 14 Oct 2026 13:32:23 +0300 Subject: [PATCH] Curvature: each three points follow the circle through them, curved ends (#1236, in part) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Curvature tool built its curve as a uniform Catmull-Rom: both handles of a smooth point a sixth of the chord between its neighbours, the ends of an open path without handles. Uneven spacing overshot or looped, a J or U from three points bent sharply at the middle with straight legs, and the end segments looked flat. - `curvature_spline` (draw2.rs) makes each stretch of three points follow the circle through them: a smooth point's tangent is that circle's (`smooth_handles`), each handle `chord / (3 cos²(α/2))` long, α between handle and chord capped at 90° (`arc_handle`). The ends of an open path mirror their neighbour's tangent in their chord (`end_handle`). A turn tighter than 30° (`TIGHT_TURN`) shortens its handles, so a narrow V comes to a point. Degenerate points become corners. `path.curvature` and the rubber band use it. - `catmull_rom` is unchanged for the Pencil and Paintbrush fitting. - `bend_last` (from: smooth), `curvature_move` on a smooth point, the point `curvature_insert` adds on a straight segment, and `path.convertAnchor` to smooth without x/y (`curvature_smooth`) use the same rule. - `curvature_extend` gives the new end its end handle unless Alt-clicked (`corner` param on `path.curvatureEdit`). - Neighbours of a point dragged on an existing path keep their tangents (a Pen-drawn shape isn't reshaped); the path being drawn re-curves throughout. --- crates/engine/src/cmd/draw2.rs | 26 ++- crates/engine/src/tests_draw2.rs | 88 ++++++++ crates/tools/src/draw2.rs | 335 +++++++++++++++++++++++++++- crates/tools/src/draw2/curvature.rs | 221 +++++++++++++----- docs/gaps.md | 8 +- docs/mcp.md | 6 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 9 files changed, 623 insertions(+), 74 deletions(-) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 4ae53ed50..8d699798d 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "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 { "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 { 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 { 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 { 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 { 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(()) })?; diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 6761c371d..09af71da9 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -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(); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index a060e1487..37deedc69 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -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> { }) } +/// 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 { + 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 = 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 = + (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 [ diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 77391ca64..a8b4784a7 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -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; 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; 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 { if seg >= sp.segment_count() { return None; @@ -171,19 +183,49 @@ pub fn curvature_insert(sp: &mut SubPath, seg: usize, t: f64) -> Option { 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| 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 { - 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); diff --git a/docs/gaps.md b/docs/gaps.md index 4a8226a23..2cf46d879 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **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; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -58,6 +58,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Improved:** Curvature uses circular point markers without direction handles or duplicate markers (#1236, in part); real egui frames cover creation, existing paths, Anchor Size and Hide Edges. The request to change interpolation of existing paths remains open. +- **Improved:** the Curvature tool's curve: each stretch of three points follows the circle through + them (handles along its tangent, `chord / (3 cos²(α/2))` long) and the ends of an open path mirror + their neighbour's tangent, so a J or a U curves evenly, a turn tighter than 30° comes to a point, + and uneven spacing doesn't loop (#1236, in part). The rubber band, dragged, inserted and converted + points use the same rule. Re-curving a dragged point's neighbours on an existing path remains open. - **Improved:** Eyedropper has an original pipette cursor at its sampling tip; Use Precise Cursors keeps the crosshair. Bitmap/DPI and preference checks cover the distinction (#1269). OS cursor display and Caps Lock remain unverified. @@ -296,6 +301,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: Curvature curve geometry (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; #1236, in part); neighbour re-curving on existing paths remains open; estimates unchanged | | 2026-10-15 | minor | G11: Transform Each Reflect X flips left-right and Reflect Y top-bottom (#1910). Estimates unchanged. | | 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 | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | diff --git a/docs/mcp.md b/docs/mcp.md index 77dd26fdb..1510a2a82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5722106f4..ca4b69efa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **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; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; 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; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; 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; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -82,6 +82,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | M2: Curvature curve geometry: each three points follow the circle through them, curved open ends (#1236, in part); estimates unchanged | | 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 | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682); With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630); Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836); The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824); With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter); Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631); The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751); An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629); Redo during a drag keeps the drag as a step (#1856, #1719); Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter); Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809); The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634); Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738); Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735); macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855); Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767); Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744); Export for Screens and the Asset Export panel offer TIFF (#1632); SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695); Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717); Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683); A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694); krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875); milestone rows and estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index df855af05..eb4448d39 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **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; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **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; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -462,7 +462,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing modes:** in Draw Inside, a double-click with a selection tool outside the object being drawn into sets the drawing mode to Draw Normal, as Shift+D does. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). - Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). + Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests_draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). @@ -638,6 +638,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Drawing: Curvature curve geometry (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; #1236, in part); score and hours unchanged (estimated) | | 2026-10-14 | minor | Image Trace: tiny fitted contours retain islands and holes; generated Corners/Noise regressions; scores and estimates unchanged | | 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 | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9f9d5b636..9f83baec0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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. |