fix(selection): 線分の選択と削除をアンカーから分ける (#1278)

This commit is contained in:
Ikki Nishihara 2026-10-12 08:00:16 +09:00 committed by bflatastic
parent aa15cb52bd
commit 1ac797685f
25 changed files with 384 additions and 54 deletions

View File

@ -88,7 +88,7 @@ pub use placed_document::PlacedDocument;
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, Selection};
pub use selection::{AnchorRef, SegmentRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;

View File

@ -1,4 +1,4 @@
//! Selection state (objects and, for direct selection, individual anchors; ruler guides).
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
use std::collections::{BTreeMap, BTreeSet};
@ -8,14 +8,21 @@ use crate::{Document, NodeId};
/// (subpath index, anchor index) inside a path.
pub type AnchorRef = (usize, usize);
/// (subpath index, segment index) inside a path.
pub type SegmentRef = (usize, usize);
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Selection {
/// Selected objects in selection order.
pub objects: Vec<NodeId>,
/// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected.
/// Direct-selected anchors, or a selected segment's endpoints used for movement. A path in
/// `objects` with no entry here is fully selected; `segments` distinguishes segment picks.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
/// Explicitly selected segments. Their endpoints in `anchors` move with them, but are not
/// selected anchor points: Delete cuts connections and their anchor squares stay hollow.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub segments: BTreeMap<NodeId, BTreeSet<SegmentRef>>,
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
/// it when every selected object is the key or lies inside it, as when one object is left
/// selected or when the key is a group selected with only its own members.
@ -55,6 +62,7 @@ impl Selection {
pub fn clear(&mut self) {
self.objects.clear();
self.anchors.clear();
self.segments.clear();
self.key = None;
self.target = None;
self.slices.clear();
@ -78,6 +86,7 @@ impl Selection {
self.slices.clear();
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
self.segments.remove(&id);
if self.key == Some(id) {
self.key = None;
}
@ -116,13 +125,24 @@ impl Selection {
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
self.anchors.get(&id)
}
/// Which direction handles belong to selected segments; both for an anchor selection.
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
let Some(segments) = self.segments.get(&id) else { return (true, true) };
let previous = ai.checked_sub(1).or_else(|| sp.closed.then_some(sp.segment_count()).and_then(|n| n.checked_sub(1)));
(previous.is_some_and(|i| segments.contains(&(si, i))), segments.contains(&(si, ai)))
}
/// Drop ids that no longer exist or are no longer editable.
pub fn prune(&mut self, doc: &Document) {
// Every id this looks up, found in one walk of the tree.
let ids = self.objects.iter().chain(self.anchors.keys()).copied().chain(self.key).chain(self.target);
let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target);
let found = doc.index_paths(ids);
self.objects.retain(|id| found.contains_key(id));
self.anchors.retain(|id, _| found.contains_key(id));
self.segments.retain(|id, refs| {
let Some(path) = found.get(id).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()) else { return false };
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
});
// The key object stays while another selected object lies outside it.
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
let mut inside = BTreeSet::new();

View File

@ -781,6 +781,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
sel.remove(id);
} else {
sel.anchors.remove(&id);
sel.segments.remove(&id);
}
Ok(gone)
}

View File

@ -300,7 +300,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// Direct-selected anchors: delete those anchors instead of whole objects.
// Direct-selected anchors or segments: remove those components instead of whole objects.
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}

View File

@ -456,21 +456,35 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let sel = st.selection.anchors.clone();
let segments = st.selection.segments.clone();
s.edit("Clear", |d, selection| {
let mut survivors = vec![];
for (id, set) in &sel {
let remove_node = {
let path = path_mut(d, *id)?;
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
for &(si, ai) in set.iter().rev() {
if let Some(sp) = path.subpaths.get_mut(si)
&& ai < sp.anchors.len()
{
if sp.closed {
sp.anchors.rotate_left(ai + 1);
sp.anchors.pop();
sp.closed = false;
} else {
sp.anchors.remove(ai);
if let Some(refs) = segments.get(id) {
// A segment pick cuts its connection, keeping every neighboring cubic.
path.subpaths = std::mem::take(&mut path.subpaths)
.into_iter()
.enumerate()
.flat_map(|(si, sp)| {
let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect();
without_segments(sp, &cuts)
})
.collect();
} else {
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
for &(si, ai) in set.iter().rev() {
if let Some(sp) = path.subpaths.get_mut(si)
&& ai < sp.anchors.len()
{
if sp.closed {
sp.anchors.rotate_left(ai + 1);
sp.anchors.pop();
sp.closed = false;
} else {
sp.anchors.remove(ai);
}
}
}
}
@ -479,14 +493,52 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
};
if remove_node {
d.remove(*id)?;
} else if segments.contains_key(id) {
survivors.push(*id);
}
}
selection.clear();
// Another Delete removes the remaining path after a segment cut. Anchor Delete keeps
// its existing deselection behavior.
selection.set(survivors);
Ok(())
})?;
ok()
}
/// Remove connections, splitting open/closed contours into their surviving open runs.
fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
let cuts: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect();
let Some(&first) = cuts.first() else { return vec![sp] };
let n = sp.anchors.len();
let start = if sp.closed { (first + 1) % n } else { 0 };
let mut result = vec![];
let mut run = vec![];
let mut previous = None;
for (index, anchor) in sp.anchors.iter().enumerate().cycle().skip(start).take(n) {
if previous.is_some_and(|i| cuts.contains(&i)) {
result.extend(open_run(std::mem::take(&mut run)));
}
run.push(*anchor);
previous = Some(index);
}
result.extend(open_run(run));
result
}
fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
if anchors.len() < 2 {
return None;
}
// The cut edge's incoming/outgoing controls are no longer part of an open endpoint.
if let Some(first) = anchors.first_mut() {
first.h_in = first.p;
}
if let Some(last) = anchors.last_mut() {
last.h_out = last.p;
}
Some(SubPath::new(anchors, false))
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);

View File

@ -70,6 +70,15 @@ pub fn specs() -> Vec<CommandSpec> {
key
),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!(
"select.segments",
"Select Segments",
[],
None,
"{id, segments: [[subpath, segment]…]} select path segments; their endpoints move with them, Delete removes only their connections",
has_doc,
segments
),
cmd!(
"select.anchorsMany",
"Select Anchors",
@ -330,6 +339,27 @@ pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result<Vec<(usize, u
.collect()
}
fn segments(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.segments";
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
let refs: BTreeSet<_> = checked_refs(p.get("segments"), C)?.into_iter().collect();
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "object is not a path"))?;
let mut endpoints = BTreeSet::new();
for &(si, seg) in &refs {
let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(C, "segment does not exist"))?;
endpoints.extend([(si, seg), (si, (seg + 1) % sp.anchors.len())]);
}
s.select(|_, sel| {
sel.clear();
if !refs.is_empty() {
sel.add(id);
sel.anchors.insert(id, endpoints);
sel.segments.insert(id, refs);
}
})?;
ok()
}
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.anchors";
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
@ -347,6 +377,7 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
// A path selected as a whole has every anchor selected: toggle and add start from them all.
let whole = sel.contains(id) && sel.partial(id).is_none();
sel.add(id);
sel.segments.remove(&id);
let e = sel.anchors.entry(id).or_default();
if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) {
e.extend(path.anchors().map(|(s, i, _)| (s, i)));
@ -385,6 +416,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
sel.clear();
}
for (id, refs) in items {
sel.segments.remove(&id);
let all: BTreeSet<(usize, usize)> =
d.node(id).and_then(|n| n.path_data()).map(|p| p.anchors().map(|(s, i, _)| (s, i)).collect()).unwrap_or_default();
// Anything but a path is selected, toggled or deselected as a whole.

View File

@ -688,6 +688,112 @@ fn cut_at_anchors_splits_paths_and_leaves_one_end_selected() {
assert_eq!(undo_steps(&s), undo);
}
#[test]
fn direct_segment_delete_keeps_the_other_rectangle_edges_and_one_undo() {
for initially_selected in [false, true] {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0);
if !initially_selected {
s.execute("select.none", &json!({})).unwrap();
}
let before = s.doc().unwrap().doc.clone();
let original = path(&s, id).subpaths[0].clone();
let undo = undo_steps(&s);
drag(&mut s, "directSelection", &[(200.0, 100.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc, before, "a click never edits the document");
assert_eq!(undo_steps(&s), undo);
assert_eq!(s.doc().unwrap().selection.segments[&id], [(0, 0)].into());
s.execute("edit.clear", &json!({})).unwrap();
let after = s.doc().unwrap().doc.clone();
let remainder = path(&s, id).subpaths[0].clone();
assert!(!remainder.closed);
assert_eq!(remainder.anchors.len(), 4, "the endpoints and neighboring edges survive");
for i in 0..3 {
assert_eq!(remainder.segment(i), original.segment(i + 1));
}
assert_eq!(undo_steps(&s), undo + 1);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert!(s.doc().unwrap().selection.anchors.is_empty() && s.doc().unwrap().selection.segments.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().selection.segments[&id], [(0, 0)].into());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none(), "a second Delete removes the rest");
}
}
#[test]
fn segment_delete_preserves_curves_appearance_and_unrelated_contours() {
let data = "M100 100C110 70 150 130 170 100C180 75 230 125 250 100C270 140 280 80 310 100C320 120 140 140 100 100Z M400 100L500 100L500 200Z";
for closed in [false, true] {
for cuts in [vec![0], vec![1], vec![3], vec![0, 2], vec![0, 3], vec![0, 1, 2, 3]] {
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
s.edit("Fixture", |d, _| {
if let NodeKind::Path { path, .. } = &mut d.node_mut(id).unwrap().kind {
path.subpaths[0].closed = closed;
}
Ok(())
})
.unwrap();
s.execute("paint.setStroke", &json!({"color":"#123456"})).unwrap();
let original = path(&s, id);
let cuts: Vec<_> = cuts.into_iter().filter(|&i| i < original.subpaths[0].segment_count()).collect();
if cuts.is_empty() {
continue;
}
let refs: Vec<_> = cuts.iter().map(|i| json!([0, i])).collect();
s.execute("select.segments", &json!({"id":id.0,"segments":refs})).unwrap();
let before = s.doc().unwrap().doc.clone();
s.execute("edit.clear", &json!({})).unwrap();
let result = path(&s, id);
assert_eq!(result.subpaths.last().unwrap(), original.subpaths.last().unwrap(), "the other contour stays exact");
let expected: Vec<_> =
(0..original.subpaths[0].segment_count()).filter(|i| !cuts.contains(i)).map(|i| original.subpaths[0].segment(i)).collect();
let actual: Vec<_> =
result.subpaths.iter().take(result.subpaths.len() - 1).flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect();
assert_eq!(actual.len(), expected.len());
assert!(expected.iter().all(|curve| actual.contains(curve)), "{closed} {cuts:?}");
for sp in result.subpaths.iter().take(result.subpaths.len() - 1) {
assert!(
!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out(),
"deleted-edge handles do not remain at open endpoints"
);
}
let mut restored = s.doc().unwrap().doc.clone();
std::sync::Arc::make_mut(&mut restored).node_mut(id).unwrap().kind = before.node(id).unwrap().kind.clone();
assert_eq!(restored, before, "only the edited path kind changes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
}
}
#[test]
fn segment_picks_and_deliberate_anchor_picks_delete_different_components() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0);
s.execute("select.segments", &json!({"id":id.0,"segments":[[0,0]]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
for refs in [json!([[0, 99]]), json!([[99, 0]]), json!([[-1, 0]]), json!([[0, 0.5]])] {
assert!(s.execute("select.segments", &json!({"id":id.0,"segments":refs})).is_err());
assert_eq!(s.doc().unwrap().selection, selection);
assert_eq!(s.doc().unwrap().doc, before);
}
s.execute("select.anchors", &json!({"id":id.0,"anchors":[[0,0],[0,1]]})).unwrap();
assert!(s.doc().unwrap().selection.segments.is_empty());
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(path(&s, id).anchor_count(), 2, "deliberate anchor Delete keeps its existing behavior");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.segments", &json!({"id":id.0,"segments":[[0,0]]})).unwrap();
drag(&mut s, "directSelection", &[(100.0, 100.0)], Mods::default());
assert!(s.doc().unwrap().selection.segments.is_empty());
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 0)].into(), "clicking an endpoint picks the anchor itself");
}
#[test]
fn pen_with_alt_moves_one_handle_and_converts_anchors() {
// Discord feedback: Alt held with the Pen moves a direction handle on its own (and works as

View File

@ -54,7 +54,7 @@ impl PathWidgets {
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection.
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
if !doc.is_editable(id) {
if !doc.is_editable(id) || selection.segments.contains_key(&id) {
return None;
}
let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None };

View File

@ -1,7 +1,7 @@
//! Direct Selection (A) and Group Selection tools.
//!
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
//! segment independently of its endpoint anchors, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
//! direction handle to
@ -139,6 +139,8 @@ pub struct DirectSelectionTool {
/// The already selected anchor the press landed on: a click (no drag) selects it alone, as a
/// drag moves every selected anchor with it.
click: Option<(NodeId, usize, usize)>,
/// A click on a whole path picks the segment on release; a drag still moves the whole path.
click_segment: Option<(NodeId, usize, usize)>,
}
impl DirectSelectionTool {
@ -156,6 +158,7 @@ impl DirectSelectionTool {
hover: None,
guide: GuideEdit::default(),
click: None,
click_segment: None,
}
}
}
@ -266,7 +269,19 @@ fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
for &id in &cx.selection.objects {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
let shown = cx.selection.partial(id);
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| (id, si, ai, *a)));
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| {
let mut a = *a;
if let Some(sp) = pd.subpaths.get(si) {
let (inside, outside) = cx.selection.handle_sides(id, si, ai, sp);
if !inside {
a.h_in = a.p;
}
if !outside {
a.h_out = a.p;
}
}
(id, si, ai, a)
}));
}
if !cx.handles_multiple && v.len() > 1 {
v.clear();
@ -299,8 +314,6 @@ struct SegmentHit {
si: usize,
seg: usize,
t: f64,
/// The indexes of the segment's two anchors in its subpath.
anchors: [usize; 2],
curved: bool,
}
@ -312,7 +325,7 @@ fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
let n = sp.anchors.len();
let (i0, i1) = (seg.checked_rem(n)?, (seg + 1).checked_rem(n)?);
let (a, b) = (sp.anchors.get(i0)?, sp.anchors.get(i1)?);
Some(SegmentHit { si, seg, t, anchors: [i0, i1], curved: a.has_out() || b.has_in() })
Some(SegmentHit { si, seg, t, curved: a.has_out() || b.has_in() })
}
impl Tool for DirectSelectionTool {
@ -365,6 +378,7 @@ impl Tool for DirectSelectionTool {
}
(PointerKind::Down, _) => {
self.click = None;
self.click_segment = None;
if let Some(c) = CornerDrag::hit(cx, ev, true) {
self.state = State::Corner(c);
return vec![];
@ -406,7 +420,9 @@ impl Tool for DirectSelectionTool {
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
// A path with all its anchors selected is stored whole (no partial entry).
let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
let already = cx.selection.contains(id)
&& !cx.selection.segments.contains_key(&id)
&& cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai)));
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
// the copy on and off during the drag as with the Selection tool.
self.state = if already && cx.selection.anchors.is_empty() {
@ -438,27 +454,29 @@ impl Tool for DirectSelectionTool {
return out;
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
// A segment of a path that isn't selected as a whole: its two anchors get
// selected, and dragging it reshapes it if it's curved, else moves it. Alt picks
// A segment pick keeps its endpoints available for movement without selecting
// the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks
// the whole path (and Alt-drag copies it), as with Group Selection.
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
if !whole
&& !ev.mods.shift
if !ev.mods.shift
&& !ev.mods.alt
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
&& let Some(s) = segment_at(cx, h.leaf, p)
{
let selected = cx.selection.partial(h.leaf).is_some_and(|sel| s.anchors.iter().all(|&ai| sel.contains(&(s.si, ai))));
self.state = if s.curved {
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
if whole {
self.click_segment = Some((h.leaf, s.si, s.seg));
} else {
State::MoveAnchors { start: p, grab: p, began: false }
};
if selected {
return vec![];
let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg)));
self.state = if s.curved {
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
} else {
State::MoveAnchors { start: p, grab: p, began: false }
};
if selected {
return vec![];
}
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))];
}
let anchors: Vec<Value> = s.anchors.iter().map(|ai| json!([s.si, ai])).collect();
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
}
// Clicking the fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, grab: None, began: false };
@ -636,12 +654,13 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
match (began, self.click.take()) {
(true, _) => vec![Action::Commit],
(false, Some((id, si, ai))) => {
match (began, self.click.take(), self.click_segment.take()) {
(true, _, _) => vec![Action::Commit],
(false, Some((id, si, ai)), _) => {
vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]
}
(false, None) => vec![],
(false, None, Some((id, si, seg))) => vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))],
(false, None, None) => vec![],
}
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@ -1102,9 +1121,9 @@ mod tests {
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
// The arch: its two anchors get selected, and the drag bends it.
// The arch: its segment gets selected, and the drag bends it.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": arch.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}))]);
assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": arch.0, "segments": [[0, 0]]}))]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5));
assert_eq!(a[0], Action::Begin("Reshape".into()));
let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") };
@ -1116,7 +1135,7 @@ mod tests {
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]);
// A straight edge of the rectangle: its two anchors get selected and move.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.anchors" && v["anchors"] == json!([[0, 0], [0, 1]])), "{a:?}");
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.segments" && v["segments"] == json!([[0, 0]])), "{a:?}");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0})));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0));

View File

@ -1653,6 +1653,21 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
return;
}
let NodeKind::Path { path, .. } = &c.kind else { return };
let selected_segments = st.selection.segments.get(&c.id);
if let Some(segments) = selected_segments {
for &(si, seg) in segments {
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
let curve = sp.segment(seg);
let mut outline = vectorcraft_geom::BezPath::new();
outline.move_to(curve.p0);
if sp.segment_is_line(seg) {
outline.line_to(curve.p3);
} else {
outline.curve_to(curve.p1, curve.p2, curve.p3);
}
stroke_path(p, &outline, xf, Stroke::new(2.0, color));
}
}
for (si, ai, a) in path.anchors() {
let sel = match partial {
Some(set) => set.contains(&(si, ai)),
@ -1661,9 +1676,22 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let sp = xf.to_screen(a.p);
if sel && (direct || partial.is_some()) {
with_handles += 1;
handles.extend([a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6).map(|h| (sp, xf.to_screen(h), color)));
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
handles.extend(
[(a.h_in, sides.0), (a.h_out, sides.1)]
.into_iter()
.filter(|(h, show)| *show && h.distance(a.p) > 1e-6)
.map(|(h, _)| (sp, xf.to_screen(h), color)),
);
}
anchor_square(p, sp, if sel && partial.is_some() { selected_anchor(color) } else { color }, sel, anchor(partial.is_some() || direct));
let anchor_selected = sel && selected_segments.is_none();
anchor_square(
p,
sp,
if anchor_selected && partial.is_some() { selected_anchor(color) } else { color },
anchor_selected,
anchor(partial.is_some() || direct),
);
}
});
// Centre point (Attributes panel → Show Center).
@ -2628,6 +2656,58 @@ mod tests {
(v, out.textures_delta)
}
/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles.
#[test]
fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300})).unwrap();
app.ui.task_bar = false;
app.ui.view.rulers = false;
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":200,"height":150})).unwrap()["id"].as_u64().unwrap();
app.run("select.none", json!({})).unwrap();
app.select_tool("directSelection");
let view = app.view_info();
for kind in [PointerKind::Down, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(kind, 150.0, 50.0), view).unwrap();
}
let before = app.session.doc().unwrap().doc.clone();
let color = c32(before.layer_color(vectorcraft_doc::NodeId(id)));
let selected = selected_anchor(color);
let ctx = egui::Context::default();
let painted = shapes(&mut app, &ctx);
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 0);
assert_eq!(
painted.iter().filter(|s| matches!(s, Shape::Path(p) if p.stroke.width == 2.0 && p.points.len() == 2)).count(),
1,
"only the chosen straight edge has emphasis"
);
assert!(!painted.iter().any(|s| matches!(s, Shape::Circle(_))), "segment picks have no corner widgets");
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("select.anchors", json!({"id":id,"anchors":[[0,0],[0,1]]})).unwrap();
let painted = shapes(&mut app, &ctx);
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 2);
app.run(
"path.create",
json!({"anchors":[{"x":50,"y":260,"out":[100,220]},{"x":200,"y":260,"in":[150,220],"out":[250,300]},{"x":330,"y":260,"in":[280,300]}]}),
)
.unwrap();
app.run("select.none", json!({})).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
app.session.pointer(&PointerEvent::new(kind, 125.0, 230.0), view).unwrap();
}
let before = app.session.doc().unwrap().doc.clone();
let painted = shapes(&mut app, &ctx);
assert_eq!(
painted.iter().filter(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)).count(),
2,
"only the chosen curve's two controls show"
);
app.ui.view.edges = false;
let painted = shapes(&mut app, &ctx);
assert!(!painted.iter().any(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)));
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one
/// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas
/// renders for the screen again.

View File

@ -3101,6 +3101,7 @@ theme Appearance Motiv
Untitled Bez názvu
Add to Selection Přidat do výběru
Select Anchors Vybrat kotevní body
Select Segments Vybrat segmenty
Select Objects Vybrat objekty
Toggle Selection Přepnout výběr
Create Slice Vytvořit řez

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2449,6 +2449,7 @@
Select an image to see its Link Info. Wählen Sie ein Bild aus, um seine Verknüpfungsinformationen anzuzeigen.
Select an image to trace Bild zum Nachzeichnen auswählen
Select Anchors Ankerpunkte auswählen
Select Segments Segmente auswählen
Select Appearance Item Aussehenselement auswählen
Select Art Grafik auswählen
Select art and choose Object › Pattern › Make. Wählen Sie eine Grafik aus und wählen Sie „Objekt › Muster › Erstellen“.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2500,6 +2500,7 @@
Select an image to see its Link Info. Selecciona una imagen para ver su información de vínculo.
Select an image to trace Selecciona una imagen para calcar
Select Anchors Seleccionar anclas
Select Segments Seleccionar segmentos
Select Appearance Item Seleccionar elemento de apariencia
Select Art Seleccionar ilustración
Select art and choose Object › Pattern › Make. Selecciona una ilustración y elige Objeto › Motivo › Crear.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2497,6 +2497,7 @@
Select an image to see its Link Info. Sélectionnez une image pour afficher les informations sur son lien.
Select an image to trace Sélectionnez une image à vectoriser
Select Anchors Sélectionner les points d’ancrage
Select Segments Sélectionner les segments
Select Appearance Item Sélectionner un élément d’aspect
Select Art Sélectionner l’illustration
Select art and choose Object › Pattern › Make. Sélectionnez une illustration et choisissez Objet › Motif › Créer.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2500,6 +2500,7 @@
Select an image to see its Link Info. Seleziona un'immagine per vederne le informazioni sul collegamento.
Select an image to trace Seleziona un'immagine da ricalcare
Select Anchors Seleziona punti di ancoraggio
Select Segments Seleziona segmenti
Select Appearance Item Seleziona elemento aspetto
Select Art Seleziona grafica
Select art and choose Object › Pattern › Make. Seleziona la grafica e scegli Oggetto › Motivo › Crea.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2359,6 +2359,7 @@
Select All Instances すべてのインスタンスを選択
Select All Unused 未使用項目をすべて選択
Select Anchors アンカーを選択
Select Segments パスのセグメントを選択
Select Appearance Item アピアランス項目を選択
Select Freeform Point フリーフォームポイントを選択
Select Gradient Stop グラデーションの分岐点を選択

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2073,6 +2073,7 @@
Select All Instances Selecionar Todas as Instâncias
Select All Unused Selecionar Todos os Não Usados
Select Anchors Selecionar Âncoras
Select Segments Selecionar segmentos
Select Appearance Item Selecionar Item de Aparência
Select Freeform Point Selecionar Ponto Livre
Select Gradient Stop Selecionar Parada de Gradiente

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2496,6 +2496,7 @@
Select an image to see its Link Info. Выберите изображение, чтобы увидеть сведения о связи.
Select an image to trace Выберите изображение для трассировки
Select Anchors Выделить опорные точки
Select Segments Выделить сегменты
Select Appearance Item Выделить элемент оформления
Select Art Выделить рисунок
Select art and choose Object › Pattern › Make. Выделите рисунок и выберите «Объект › Узор › Создать».

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2492,6 +2492,7 @@
Select an image to see its Link Info. Виберіть зображення, щоб побачити зв’язок.
Select an image to trace Виберіть зображення для трасування
Select Anchors Виділити опорні точки
Select Segments Виділити сегменти
Select Appearance Item Виділити елемент оформлення
Select Art Виділити малюнок
Select art and choose Object › Pattern › Make. Виділіть малюнок та виберіть «Об’єкт › Візерунок › Створити».

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -2392,6 +2392,7 @@
Select an image to see its Link Info. 选择图像以查看其链接信息。
Select an image to trace 选择要描摹的图像
Select Anchors 选择锚点
Select Segments 选择路径段
Select Appearance Item 选择外观项目
Select art and choose Object › Pattern › Make. 选择图稿并选择“对象 › 图案 › 创建”。
Select art and click New Symbol to make one. 选择图稿并单击“新建符号”以创建。

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2478,6 +2478,7 @@
Select an image to see its Link Info. 選取影像以檢視其連結資訊。
Select an image to trace 選取要描圖的影像
Select Anchors 選取錨點
Select Segments 選取路徑區段
Select Appearance Item 選取外觀項目
Select Art 選取圖稿
Select art and choose Object › Pattern › Make. 選取圖稿,然後選擇「物件 › 圖樣 › 製作」。

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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
@ -87,7 +87,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G11 Correctness bugs in core tools
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
- **Done:** Direct Selection picks a segment independently of its anchors and Delete preserves neighboring edges (#1278);
scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885);
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
@ -205,6 +206,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G11: Direct Selection segment Delete retains neighboring geometry and appearance (#1278); estimates unchanged |
| 2026-10-12 | minor | G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged |
| 2026-10-11 | minor | G15: large-selection edits, cached live effects and faster Pathfinder (#1173); a 25k-path perf run meets every budget; estimate unchanged |
| 2026-10-11 | minor | G4: Middle Eastern digit types done (Character panel menu › Digits); estimate unchanged |

View File

@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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-12 · **Change:** minor (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
@ -49,6 +49,8 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
@ -79,6 +81,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M2: independent segment selection/display and geometry-preserving Delete (#1278); estimates unchanged |
| 2026-10-12 | minor | M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged |
| 2026-10-11 | minor | M12: the Graph Data worksheet. Scores/hours unchanged. |
| 2026-10-11 | minor | M12: graph label formatting kept through regeneration. Scores/hours unchanged. |

View File

@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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) · **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),
@ -67,7 +67,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Area | Weight | Done | Main gaps | One agent (h) | Kind |
|---|---:|---:|---|---:|---|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated |
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances | 4–6 | estimated |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners' Relative rounding; see [ui-parity.md](ui-parity.md) | 8–13 | estimated |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged |
@ -296,7 +296,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners' Relative rounding | 8–13 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
@ -413,6 +413,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending.
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. Column designs remain open.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
@ -468,6 +469,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Selection: segment picks are distinct from anchor picks; Delete preserves neighboring geometry (#1278), egui/native checks; scores unchanged |
| 2026-10-12 | minor | Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged |
| 2026-10-11 | minor | The Graph Data worksheet (Import Data, Cell Style, Transpose, Switch x/y, Revert). Scores unchanged within rounding |
| 2026-10-11 | minor | Graph label formatting kept through regeneration. Scores unchanged within rounding |

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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-12 · **Change:** minor (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,
@ -63,13 +63,14 @@ tools, never checked side by side) isn't captured row by row.
|---|---|---|
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
| A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 |
| Click a segment independently of its endpoint anchors; only its direction handles show | done | #1278: hollow endpoint squares, selected edge emphasized; headless egui checked |
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 |
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides |
| Show handles when multiple anchors are selected; Highlight anchors on mouse over; anchor and handle size | done | Preferences › Selection & Anchor Display |
| Convert selected anchors to corner/smooth from the Control bar and Properties | done | |
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | |
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | clicking a widget without pre-selecting the anchor (#908) missing |
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
| Isolation mode by double-click, breadcrumbs, Esc to exit | done | |
@ -173,6 +174,7 @@ re-measure of the panels added since.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Direct Selection: segment click/display and Delete preserve neighboring edges (#1278); pointer/egui/native checks, OS visual QA pending; score unchanged |
| 2026-10-12 | minor | Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged |
| 2026-10-12 | minor | macOS native save sheets gain a localised Format menu (#866); filename regression tests and an actual SVG save, PDF selection and cancellation verified; scores unchanged (estimated) |
| 2026-10-12 | minor | Attributes: directly selected compound members read and change the compound fill rule; path direction stays member-local (#1197); score unchanged |