fix(selection): 線分の選択と削除をアンカーから分ける (#1278)
This commit is contained in:
parent
aa15cb52bd
commit
1ac797685f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")?);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue