Merge remote-tracking branch 'upstream/main' into fix/eps-memory-cap

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
Don Omondi
2026-11-09 01:53:05 -05:00
19 changed files with 797 additions and 76 deletions
+17 -9
View File
@@ -1027,6 +1027,12 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if !direct { clear(s, &json!({ "ids": roots, "guides": true })) } else { cut_direct_selection(s, &roots) }
}
/// Remove the geometry copied by a direct-selection Cut, including selected guides in one edit.
/// Select every surviving piece while retaining intentional compound-path structure.
/// `roots` contains the copied targets: whole-object roots are removed in full, while partial paths
/// lose only the copied geometry. An exhausted partial path is removed, along with its compound
/// parent if now empty; layers remain. Returns JSON `null` on success, replacing direct-selection
/// references with surviving object IDs.
fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
let st = s.doc()?;
let sel = st.selection.clone();
@@ -1035,6 +1041,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
let gone = selection.guides.clone();
d.retain_guides(selection, |i, _| !gone.contains(&i));
let mut survivors = vec![];
let mut pieces = super::pathops::Beside::replacing(d, roots);
for id in roots {
let Some(node) = d.node(*id) else { continue };
let has_direct = sel.anchors.contains_key(id) || sel.segments.contains_key(id);
@@ -1042,20 +1049,22 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
d.remove(*id)?;
continue;
}
let Some(path) = node.path_data() else {
if node.path_data().is_none() {
d.remove(*id)?;
continue;
};
}
let original = super::path::deletion_source(d, *id)?;
let seg_refs = sel.segments.get(id);
let anchor_refs = sel.anchors.get(id);
let mut subpaths = Vec::new();
for (si, sp) in path.subpaths.iter().enumerate() {
let cuts = super::clipboard::subpath_selected_segments(sp, si, seg_refs, anchor_refs);
let owned = std::mem::take(&mut super::path::path_mut(d, *id)?.subpaths);
for (si, sp) in owned.into_iter().enumerate() {
let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs);
if cuts.is_empty() {
subpaths.push(sp.clone());
subpaths.push(sp);
} else {
subpaths.extend(super::path::without_segments(sp.clone(), &cuts));
subpaths.extend(super::path::without_segments(sp, &cuts));
}
}
subpaths.retain(|sp| sp.anchors.len() >= 2);
@@ -1068,11 +1077,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
d.remove(p)?;
}
} else {
let path_node = super::path::path_mut(d, *id)?;
path_node.subpaths = subpaths;
survivors.push(*id);
survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?);
}
}
pieces.finish(d)?;
selection.set(survivors);
selection.anchors.clear();
selection.segments.clear();
+57 -10
View File
@@ -1,6 +1,7 @@
//! Path editing commands (used by the Pen and Direct Selection tools and Object → Path).
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
@@ -1206,6 +1207,11 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Delete direct-selected anchors or segments and whole selected objects in one edit.
/// Leave surviving pieces selected, splitting ordinary contours into independent sibling objects.
/// `p.guides: true` also removes the selected ruler guides in that edit; otherwise guides remain.
/// Layers are retained, and an object removed with a selected ancestor is not removed twice.
/// Returns JSON `null` on success; selection is replaced by the surviving object IDs.
pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
// `guides: true` (Cut, #3226): the selected ruler guides go in the same undo step.
let with_guides = switch_or(p, "guides", false, "path.deleteAnchors")?;
@@ -1237,11 +1243,13 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
if d.node(*id).is_none() {
continue;
}
let remove_node = {
let original = deletion_source(d, *id)?;
let subpaths: Vec<SubPath> = {
let path = path_mut(d, *id)?;
let subpaths = std::mem::take(&mut path.subpaths);
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)
subpaths
.into_iter()
.enumerate()
.flat_map(|(si, sp)| {
@@ -1249,11 +1257,11 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
without_segments(sp, &cuts)
})
.filter(|s| s.anchors.len() >= 2)
.collect();
.collect()
} else {
// Remove every selected anchor against the original indexes, keeping surviving
// stretches separate rather than joining across deleted anchors.
path.subpaths = std::mem::take(&mut path.subpaths)
subpaths
.into_iter()
.enumerate()
.flat_map(|(si, sp)| {
@@ -1261,16 +1269,14 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
without_anchors(sp, &gone)
})
.filter(|s| s.anchors.len() >= 2)
.collect();
.collect()
}
path.subpaths.retain(|s| s.anchors.len() >= 2);
path.subpaths.is_empty()
};
if remove_node {
if subpaths.is_empty() {
emptied.put(d, *id, vec![])?;
} else {
// The remainder is selected whole, so the next Delete removes it.
survivors.push(*id);
// Every remaining piece is selected whole, so the next Delete removes them.
survivors.extend(replace_deleted_pieces(d, &mut emptied, original, subpaths)?);
}
}
emptied.finish(d)?;
@@ -1280,6 +1286,47 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Retain path `id` from `d` without copying its geometry before deletion takes its subpaths.
/// The shared snapshot supplies the original path classification and, only if needed, gradient
/// bounds after the working node's geometry has moved. Returns `NoNode` for a missing/non-path ID.
pub(super) fn deletion_source(d: &Document, id: NodeId) -> Result<Arc<Node>> {
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
// Verify the cached position still names this path before retaining the shared node.
d.children(parent)
.and_then(|children| children.get(index))
.filter(|n| n.id == id && n.path_data().is_some())
.cloned()
.ok_or(EngineError::NoNode(id))
}
/// Replace the deleted path in `d` with the nonempty surviving runs in `subpaths`.
/// `original` must be retained by [`deletion_source`] before taking the working node's geometry.
/// An ordinary single-contour source becomes siblings; multi-subpath paths and compound members
/// retain their node. Only actual splits pin unplaced gradients to `original`'s bounds; already
/// placed gradients and connected remainders retain their placement behavior.
///
/// Returns the surviving IDs in `subpaths` order (the first keeps the source ID). The original
/// node's geometry is updated immediately. Replacements are handed to `beside`, which may defer
/// insertion; the caller must finish that [`Beside`] before using new IDs in `d`.
/// Missing/invalid paths return an error.
pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, original: Arc<Node>, subpaths: Vec<SubPath>) -> Result<Vec<NodeId>> {
let id = original.id;
let single = original.path_data().is_some_and(|p| p.subpaths.len() == 1);
if single && compound_or_self(d, id) == id {
// Pin only actual splits: each new object's smaller bounds would otherwise refit its gradients.
if subpaths.len() > 1
&& original.appearance.has_unplaced_gradient()
&& let Some(bounds) = original.geometric_bounds()
{
d.node_mut(id).ok_or(EngineError::NoNode(id))?.appearance.pin_gradients(bounds);
}
super::draw2::replace_with_pieces(d, beside, id, subpaths.into_iter().map(PathData::single).collect())
} else {
path_mut(d, id)?.subpaths = subpaths;
Ok(vec![id])
}
}
/// Remove anchors and their incident segments, keeping each surviving stretch as an open path.
/// Only a closed path can have a surviving stretch that wraps past the last anchor.
fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
+36 -12
View File
@@ -2698,11 +2698,26 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
// Cut takes the segment between them out of the path, as with Direct Selection chosen.
// Cut takes the segment between them. The two surviving runs of an ordinary path
// become independent objects, and this path keeps the first run (#3380).
s.execute("edit.undo", &json!({})).unwrap();
let original = path(&s, id);
let picked = s.doc().unwrap().selection.clone();
s.execute("edit.cut", &json!({})).unwrap();
let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::<Vec<_>>();
assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2]));
let left = paths(&s);
assert_eq!(left.len(), 2);
assert_eq!(left[0].0, id);
assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed));
assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]);
assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]);
assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::<Vec<_>>());
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paths(&s), vec![(id, original.clone())]);
assert_eq!(s.doc().unwrap().selection, picked);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(paths(&s), left);
// Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
@@ -3412,6 +3427,7 @@ fn a_filled_pencil_path_keeps_no_stroke() {
assert!(!s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint().is_none());
}
/// Interior and endpoint deletions preserve each surviving run without adding connecting segments.
#[test]
fn open_anchor_delete_splits_runs_without_new_connections() {
let cases = [
@@ -3439,16 +3455,19 @@ fn open_anchor_delete_splits_runs_without_new_connections() {
assert!(s.doc().unwrap().doc.node(id).is_none(), "only isolated points remain: {removed:?}");
assert!(s.doc().unwrap().selection.objects.is_empty());
} else {
let result = path(&s, id);
assert_eq!(result.subpaths.len(), expected.len(), "deleted anchors: {removed:?}");
for (sp, indices) in result.subpaths.iter().zip(&expected) {
let pieces = &s.doc().unwrap().selection.objects;
assert_eq!(pieces.len(), expected.len(), "deleted anchors: {removed:?}");
assert_eq!(pieces[0], id);
for (piece, indices) in pieces.iter().zip(&expected) {
let result = path(&s, *piece);
assert_eq!(result.subpaths.len(), 1);
let sp = &result.subpaths[0];
assert!(!sp.closed);
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), indices.iter().map(|&i| original.anchors[i].p).collect::<Vec<_>>());
for (segment, pair) in indices.windows(2).enumerate() {
assert_eq!(sp.segment(segment), original.segment(pair[0]));
}
}
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
}
assert_eq!(undo_steps(&s), undo + 1);
assert!(s.doc().unwrap().selection.anchors.is_empty() && s.doc().unwrap().selection.segments.is_empty());
@@ -3547,6 +3566,7 @@ fn open_anchor_delete_does_not_change_explicit_remove_anchors() {
}
}
/// Baked transforms and mixed whole/partial selection survive splitting and atomic Undo/Redo.
#[test]
fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
use vectorcraft_geom::Affine;
@@ -3584,13 +3604,17 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
let st = s.doc().unwrap();
assert!(st.doc.node(whole).is_none());
assert_eq!(st.doc.node(untouched), before.node(untouched));
let result = path(&s, id);
assert_eq!(result.subpaths.len(), 2);
for (sp, segment) in result.subpaths.iter().zip([0, 3]) {
let pieces = st.selection.objects.clone();
assert_eq!(pieces.len(), 2);
assert_eq!(pieces[0], id);
for (piece, segment) in pieces.iter().zip([0, 3]) {
let result = path(&s, *piece);
assert_eq!(result.subpaths.len(), 1);
let sp = &result.subpaths[0];
assert_eq!(sp.segment_count(), 1);
assert_eq!(sp.segment(0), original.subpaths[0].segment(segment));
}
assert_eq!(st.selection.objects, vec![id]);
assert_eq!(st.selection.objects, pieces);
assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty());
assert_eq!(undo_steps(&s), undo + 1);
let after = st.doc.clone();
@@ -3599,7 +3623,7 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert_eq!(s.doc().unwrap().selection.objects, pieces);
}
}
@@ -751,6 +751,7 @@ fn cut_anchor_of_closed_path_opens_it_with_single_undo_step() {
check_session(&s).unwrap();
}
/// Cutting an interior connection copies that segment and leaves two selected sibling objects.
#[test]
fn cut_middle_segment_of_open_path_splits_into_two_open_paths_with_single_undo_step() {
let mut s = Session::new();
@@ -772,16 +773,20 @@ fn cut_middle_segment_of_open_path_splits_into_two_open_paths_with_single_undo_s
// Source path should now be split into two surviving open runs.
let src_node = s.doc().unwrap().doc.node(id).unwrap();
let src_path = src_node.path_data().unwrap();
assert_eq!(src_path.subpaths.len(), 2, "two open paths/subpaths remain after cutting middle segment");
assert_eq!(src_path.subpaths.len(), 1);
assert!(!src_path.subpaths[0].closed);
assert_eq!(src_path.subpaths[0].anchors.len(), 2);
assert_eq!(src_path.subpaths[0].anchors[0].p.x, 10.0);
assert_eq!(src_path.subpaths[0].anchors[1].p.x, 50.0);
assert!(!src_path.subpaths[1].closed);
assert_eq!(src_path.subpaths[1].anchors.len(), 2);
assert_eq!(src_path.subpaths[1].anchors[0].p.x, 90.0);
assert_eq!(src_path.subpaths[1].anchors[1].p.x, 130.0);
let pieces = s.doc().unwrap().selection.objects.clone();
assert_eq!(pieces.len(), 2, "two independent path objects remain after cutting the middle segment");
assert_eq!(pieces[0], id);
let second = &s.doc().unwrap().doc.node(pieces[1]).unwrap().path_data().unwrap().subpaths[0];
assert!(!second.closed);
assert_eq!(second.anchors.len(), 2);
assert_eq!(second.anchors[0].p.x, 90.0);
assert_eq!(second.anchors[1].p.x, 130.0);
// Clipboard holds the cut middle segment:
assert_eq!(s.clipboard.nodes.len(), 1);
@@ -800,7 +805,9 @@ fn cut_middle_segment_of_open_path_splits_into_two_open_paths_with_single_undo_s
// Redo re-applies the split:
run(&mut s, "edit.redo", json!({}));
let redone_path = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap();
assert_eq!(redone_path.subpaths.len(), 2);
assert_eq!(redone_path.subpaths.len(), 1);
assert_eq!(s.doc().unwrap().selection.objects, pieces);
assert!(s.doc().unwrap().doc.node(pieces[1]).is_some());
check_session(&s).unwrap();
}
@@ -0,0 +1,243 @@
//! Original synthetic geometry for the deletion half of #3380; no external fixtures.
use serde_json::json;
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, NodeId, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_geom::{Affine, Anchor, PathData, Rect, SubPath};
use vectorcraft_testkit::{invariants::check_session, raster::render_artboard};
/// Create the same rectangle for the edited path and its stacking-order neighbors.
fn rectangle(s: &mut Session) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 80, "height": 60})).unwrap()["id"].as_u64().unwrap())
}
/// Start a small document suitable for exact geometry and rendered-history assertions.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
/// Clear, explicit anchor deletion and Cut preserve styled survivors, sibling order and history.
#[test]
fn deleting_opposite_rectangle_segments_makes_independent_styled_objects_and_undoes_once() {
for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] {
let mut s = session();
let below = rectangle(&mut s);
let id = rectangle(&mut s);
let above = rectangle(&mut s);
s.execute("select.set", &json!({"ids": [below.0, id.0, above.0]})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
{
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap();
n.name = Some("Styled rectangle".into());
n.opacity = 0.65;
n.transform(Affine::translate((12.0, 8.0)) * Affine::rotate(0.23) * Affine::scale_non_uniform(1.1, 0.8), true);
n.appearance =
Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.2, 0.4, 0.8)), 4.0)
.into();
}
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [0, 3]]})).unwrap();
let original = s.doc().unwrap().doc.node(id).unwrap().clone();
let before_doc = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let before_selection = s.doc().unwrap().selection.clone();
let history = s.doc().unwrap().history.undo.len();
s.execute(command, &json!({})).unwrap();
let st = s.doc().unwrap();
let children = st.doc.node(group).unwrap().children().unwrap();
assert_eq!(children.len(), 4, "{command}: two independent pieces between the existing siblings");
assert_eq!([children[0].id, children[1].id, children[3].id], [below, id, above]);
let ids = [id, children[2].id];
assert_eq!(st.selection.objects, ids);
assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty());
assert_eq!(st.history.undo.len(), history + 1);
// The two surviving sides, in contour order, carry every node property and baked transform.
let anchors = &original.path_data().unwrap().subpaths[0].anchors;
for (piece, indexes) in ids.into_iter().zip([[2, 3], [0, 1]]) {
let mut expected = original.clone();
expected.id = piece;
let mut run = vec![anchors[indexes[0]], anchors[indexes[1]]];
run[0].h_in = run[0].p;
run[1].h_out = run[1].p;
if let NodeKind::Path { path, live, .. } = &mut expected.kind {
*path = PathData::single(SubPath::new(run, false));
*live = None;
}
assert_eq!(st.doc.node(piece).unwrap(), &expected, "{command}");
}
let rendered = render_artboard(&st.doc);
let after_doc = serde_json::to_value(&st.doc).unwrap();
let after_selection = st.selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before_doc);
assert_eq!(s.doc().unwrap().selection, before_selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), after_doc);
assert_eq!(s.doc().unwrap().selection, after_selection);
assert_eq!(render_artboard(&s.doc().unwrap().doc), rendered);
check_session(&s).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert!(ids.iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()));
assert!(s.doc().unwrap().doc.node(below).is_some() && s.doc().unwrap().doc.node(above).is_some());
}
}
/// Removing separated anchors retains each cubic run and retracts only its unused endpoint handles.
#[test]
fn deleting_separated_anchors_splits_an_open_path_and_keeps_cubic_handles() {
let mut s = session();
let id = rectangle(&mut s);
let anchors: Vec<_> = (0..8)
.map(|i| {
let mut a = Anchor::corner((20.0 + f64::from(i) * 15.0, 50.0).into());
a.h_in = a.p + (-4.0, -8.0);
a.h_out = a.p + (4.0, 8.0);
a
})
.collect();
*std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap() =
PathData::single(SubPath::new(anchors.clone(), false));
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 5]]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.selection.objects.len(), 3);
for (piece, start) in st.selection.objects.iter().zip([0, 3, 6]) {
let pd = st.doc.node(*piece).unwrap().path_data().unwrap();
assert_eq!(pd.subpaths.len(), 1);
let sp = &pd.subpaths[0];
assert!(!sp.closed);
assert_eq!(sp.anchors.len(), 2);
assert_eq!(sp.anchors[0].p, anchors[start].p);
assert_eq!(sp.anchors[0].h_out, anchors[start].h_out);
assert_eq!(sp.anchors[1].h_in, anchors[start + 1].h_in);
assert_eq!(sp.anchors[0].h_in, sp.anchors[0].p);
assert_eq!(sp.anchors[1].h_out, sp.anchors[1].p);
}
check_session(&s).unwrap();
}
/// Existing multi-subpath paths and explicit compound members retain structure and exact geometry.
#[test]
fn deletion_preserves_intentional_multi_subpaths_and_compound_members() {
for command in ["edit.clear", "edit.cut"] {
for compound in [false, true] {
let mut s = session();
let id = rectangle(&mut s);
let other = rectangle(&mut s);
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(other).unwrap().transform(Affine::translate((110.0, 0.0)), true);
let extra = s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].clone();
let original = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
assert_ne!(extra, original, "the untouched contour must be distinguishable from the edited one");
let container = if compound {
s.execute("select.set", &json!({"ids": [id.0, other.0]})).unwrap();
NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap())
} else {
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap().subpaths.push(extra.clone());
s.doc().unwrap().doc.parent_of(id).unwrap()
};
let children = s.doc().unwrap().doc.node(container).unwrap().children().unwrap().len();
let sibling = s.doc().unwrap().doc.node(other).unwrap().clone();
let original = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let mut expected = vec![
SubPath::new(vec![original.anchors[2], original.anchors[3]], false),
SubPath::new(vec![original.anchors[0], original.anchors[1]], false),
];
if !compound {
expected.push(extra);
}
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [0, 3]]})).unwrap();
s.execute(command, &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.node(container).unwrap().children().unwrap().len(), children);
assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths.len(), if compound { 2 } else { 3 });
assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths, expected, "{command}, compound: {compound}");
assert_eq!(st.doc.node(other).unwrap(), &sibling);
assert_eq!(st.selection.objects, [id]);
check_session(&s).unwrap();
}
}
}
/// Splitting keeps the original gradient fit on every piece and leaves placed vectors unchanged.
#[test]
fn deletion_split_preserves_gradient_placement() {
for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for placed in [false, true] {
let mut s = session();
let id = rectangle(&mut s);
let mut fill = GradientPaint::new(Gradient { kind, ..Default::default() });
fill.angle = 45.0;
let mut stroke = GradientPaint::new(Gradient { kind, ..Default::default() });
stroke.angle = 90.0;
if placed {
fill.geom = Some(GradientGeom::fit(kind, Rect::new(20.0, 10.0, 160.0, 150.0), 35.0));
stroke.geom = Some(GradientGeom::fit(kind, Rect::new(10.0, 20.0, 150.0, 160.0), -25.0));
}
{
let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap();
n.transform(Affine::translate((10.0, 5.0)) * Affine::rotate(0.15), false);
n.appearance = Appearance::basic(Paint::Gradient(Box::new(fill.clone())), Paint::Gradient(Box::new(stroke.clone())), 8.0).into();
}
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [0, 3]]})).unwrap();
let st = s.doc().unwrap();
let before = st.doc.clone();
let before_selection = st.selection.clone();
let history = st.history.undo.len();
let original = before.node(id).unwrap();
let bounds = original.geometric_bounds().unwrap();
let fill_geom = fill.resolve(bounds);
let stroke_geom = stroke.resolve(bounds.inflate(4.0, 4.0));
// The old single-node remainder keeps the original full bounds. Its rendering is
// an independent reference for the two new objects, including unplaced gradients.
let mut expected = (*before).clone();
let a = &original.path_data().unwrap().subpaths[0].anchors;
if let NodeKind::Path { path, live, .. } = &mut expected.node_mut(id).unwrap().kind {
*path = PathData::new(vec![SubPath::new(vec![a[2], a[3]], false), SubPath::new(vec![a[0], a[1]], false)]);
*live = None;
}
let expected_image = render_artboard(&expected);
assert!(expected_image.ink() > 0);
s.execute(command, &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.selection.objects.len(), 2);
assert_eq!(st.history.undo.len(), history + 1);
assert!(render_artboard(&st.doc) == expected_image, "{command}, {kind:?}, placed: {placed}: gradient colors moved");
for piece in &st.selection.objects {
let n = st.doc.node(*piece).unwrap();
for (paint, expected_geom) in [(n.appearance.fill_paint(), fill_geom), (n.appearance.stroke_paint(), stroke_geom)] {
let Paint::Gradient(g) = paint else { panic!("gradient was replaced") };
assert_eq!(g.geom, Some(expected_geom), "{command}, {kind:?}, placed: {placed}");
}
}
let after = st.doc.clone();
let after_selection = st.selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().selection, before_selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
assert_eq!(s.doc().unwrap().selection, after_selection);
assert!(render_artboard(&s.doc().unwrap().doc) == expected_image);
check_session(&s).unwrap();
}
}
}
}
/// A connected remainder keeps automatic fitting; pinning is needed only for separate objects.
#[test]
fn deleting_one_segment_keeps_unplaced_gradients() {
let mut s = session();
let id = rectangle(&mut s);
let paint = Paint::Gradient(Box::new(GradientPaint::new(Default::default())));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance = Appearance::basic(paint.clone(), paint, 8.0).into();
let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1]]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, [id]);
assert_eq!(st.doc.node(id).unwrap().appearance, appearance);
assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths.len(), 1);
}
+1
View File
@@ -14,6 +14,7 @@ mod beyond_canvas;
mod clear_commands;
mod compound_attributes;
mod copy_direct_selection;
mod delete_path_pieces;
mod dimensions;
mod eyedropper_sources;
mod find_replace_paras;
+20 -1
View File
@@ -292,7 +292,9 @@ impl Interp<'_> {
// there are too many).
let mut visits = 0;
let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS);
let stitched = points.is_some();
// A sampled function is continuous: no hard colour changes at its samples.
let stitched =
points.is_some() && !matches!(&f, Obj::Dict(d) if d.borrow().get(&Key::name("FunctionType")).and_then(Obj::as_num) == Some(0.0));
let ts: Vec<f64> = match points {
Some(mut pts) => {
pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t));
@@ -452,6 +454,23 @@ fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option<Vec<f64>> {
let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num);
match num("FunctionType")? {
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
// A sampled function of one input, interpolated linearly: straight between its samples.
0.0 if depth == 0 && num("Order").unwrap_or(1.0) == 1.0 => {
let nums = |k: &str| -> Vec<f64> {
d.get(&Key::name(k)).and_then(Obj::items).map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()).unwrap_or_default()
};
let (domain, size, encode) = (nums("Domain"), nums("Size"), nums("Encode"));
let ([a, b], [s]) = (<[f64; 2]>::try_from(domain).ok()?, <[f64; 1]>::try_from(size).ok()?);
if !(1.0..=MAX_STITCH_STOPS as f64).contains(&s) || !a.is_finite() || !b.is_finite() {
return None;
}
let (e0, e1) = (encode.first().copied().unwrap_or(0.0), encode.get(1).copied().unwrap_or(s - 1.0));
if e1 == e0 || !e0.is_finite() || !e1.is_finite() {
return None;
}
// Where each sample index `k` falls in the domain, through `Encode`.
Some((0..s as usize).map(|k| a + (k as f64 - e0) * (b - a) / (e1 - e0)).filter(|x| x.is_finite()).collect())
}
3.0 => {
let items = d.get(&Key::name("Functions"))?.items()?;
// More pieces than the stop cap allows: sample the function instead (tested before
+68 -12
View File
@@ -22,6 +22,9 @@ const FUNCTION_GRID: u32 = 16;
const MAX_SAMPLES: usize = 1 << 24;
/// Most outputs (colour components) a sampled function has.
const MAX_OUTPUTS: usize = 32;
/// Most samples one value of a sampled function is interpolated from: inputs past it take the
/// nearest sample.
const MAX_TAPS: usize = 256;
/// Work one patch costs against the operation budget.
const PATCH_OPS: u64 = 64;
@@ -312,8 +315,8 @@ impl Interp<'_> {
Ok(Some(out))
}
/// A sampled function (type 0) at `x`: the samples around it, interpolated linearly for
/// one input, the nearest one for more.
/// A sampled function (type 0) at `x`: the samples around it, interpolated (multilinearly,
/// or by cubics for `Order 3`).
pub(super) fn sampled(&mut self, f: &DictRef, x: &[f64]) -> Res<Vec<f64>> {
let get = |k: &str| f.borrow().get(&Key::name(k)).cloned();
let nums =
@@ -335,6 +338,8 @@ impl Interp<'_> {
Some(Obj::File { stream, .. }) => self.peek_all(&stream)?,
_ => return ps_err("typecheck", "DataSource"),
};
// Copying the samples is work too: a shading evaluates its function once for each stop.
self.spend((bytes.len() / 256) as u64)?;
let (encode, decode) = (nums("Encode"), nums("Decode"));
let max = ((1u64 << bps) - 1) as f64;
let sample = |index: usize, j: usize| -> f64 {
@@ -360,17 +365,68 @@ impl Interp<'_> {
let e = if b != a { e0 + (xi - a) * (e1 - e0) / (b - a) } else { e0 };
pos.push(clamp_between(e, 0.0, s - 1.0));
}
if let ([p], Some(s)) = (pos.as_slice(), size.first()) {
let (lo, hi) = (p.floor() as usize, (p.ceil() as usize).min(*s as usize - 1));
let t = p - p.floor();
return Ok((0..outs).map(|j| sample(lo, j) * (1.0 - t) + sample(hi, j) * t).collect());
}
let mut index = 0;
let mut stride = 1;
// The samples each input reads and their weights: the two around it (linear), or the
// four around it for `Order 3` (cubic). The result is their tensor product.
let cubic = get("Order").and_then(|o| o.as_num()) == Some(3.0);
let mut taps: Vec<Vec<(usize, f64)>> = Vec::with_capacity(m);
let mut combos = 1usize;
for (i, p) in pos.iter().enumerate() {
index += p.round() as usize * stride;
stride *= size.get(i).copied().unwrap_or(1.0) as usize;
let last = size.get(i).copied().unwrap_or(1.0) as usize - 1;
let lo = p.floor() as usize;
let t = p - p.floor();
let at = |k: isize| (lo as isize + k).clamp(0, last as isize) as usize;
let w: Vec<(usize, f64)> = if t == 0.0 || last == 0 {
vec![(lo.min(last), 1.0)]
} else if cubic && combos.saturating_mul(4) <= MAX_TAPS {
catmull_rom(t).into_iter().zip(-1..=2).map(|(w, k)| (at(k), w)).collect()
} else if combos.saturating_mul(2) <= MAX_TAPS {
vec![(at(0), 1.0 - t), (at(1), t)]
} else {
// Too many inputs between samples to interpolate them all: the nearest (a
// fractional `Size` can put it past the last sample).
vec![((p.round() as usize).min(last), 1.0)]
};
combos = combos.saturating_mul(w.len());
taps.push(w);
}
Ok((0..outs).map(|j| sample(index, j)).collect())
// Each combination of taps reads one sample of every output.
self.spend(combos.saturating_mul(outs) as u64)?;
let mut out = vec![0.0; outs];
let mut digits = vec![0usize; m];
loop {
let (mut index, mut stride, mut weight) = (0usize, 1usize, 1.0);
for (i, (tap, d)) in taps.iter().zip(&digits).enumerate() {
let (k, w) = tap.get(*d).copied().unwrap_or((0, 0.0));
index += k * stride;
weight *= w;
stride *= size.get(i).copied().unwrap_or(1.0) as usize;
}
if weight != 0.0 {
for (j, o) in out.iter_mut().enumerate() {
*o += weight * sample(index, j);
}
}
// The next combination, the first input varying fastest.
let Some(i) = digits.iter().zip(&taps).position(|(d, t)| d + 1 < t.len()) else { break };
for d in digits.iter_mut().take(i) {
*d = 0;
}
if let Some(d) = digits.get_mut(i) {
*d += 1;
}
}
// A cubic overshoots between samples of different slopes: kept in the range.
Ok(out
.into_iter()
.enumerate()
.map(|(j, v)| clamp_between(v, range.get(2 * j).copied().unwrap_or(0.0), range.get(2 * j + 1).copied().unwrap_or(1.0)))
.collect())
}
}
/// The weights of the four samples around a point `t` of the way from the second to the third:
/// the Catmull-Rom cubic, which passes through every sample with the slope of its neighbours.
fn catmull_rom(t: f64) -> [f64; 4] {
let (t2, t3) = (t * t, t * t * t);
[(-t3 + 2.0 * t2 - t) / 2.0, (3.0 * t3 - 5.0 * t2 + 2.0) / 2.0, (-3.0 * t3 + 4.0 * t2 + t) / 2.0, (t3 - t2) / 2.0]
}
+32
View File
@@ -677,6 +677,10 @@ fn mesh_and_function_shadings_become_gradient_meshes() {
let NodeKind::Mesh(g) = &m.kind else { panic!() };
// Its first corner (the domain's origin) is the first sample.
assert_eq!(g.points[0].color, Color::rgb(1.0, 0.0, 0.0));
// Between the samples it is interpolated (PLRM 3rd ed. §3.10.1, type 0 functions), not the
// nearest sample: the middle of the 17 × 17 grid is the average of red, green, blue and black.
let mid = g.points[8 * 17 + 8].color.to_rgb_uncalibrated();
assert!(mid.iter().all(|c| (c - 0.25).abs() < 1e-3), "{mid:?}");
// Type 4 packed: 8-bit flags, coordinates and components; a triangle and one on its edge.
let tri = [[0u8, 0, 0, 255, 0, 0], [0, 255, 0, 0, 255, 0], [0, 0, 255, 0, 0, 255], [1, 255, 255, 255, 255, 0]].concat();
@@ -1141,3 +1145,31 @@ fn filter_output_counts_against_the_memory_budget() {
let Err(err) = many else { panic!("the budget ends the program") };
assert!(err.contains("too much memory"), "{err}");
}
/// A sampled function of one input in an axial shading: interpolated linearly, its gradient has a
/// stop at every sample (exact, not sampled evenly); with `Order 3` it is a cubic through the
/// samples (PLRM 3rd ed. §3.10.1, Table 3.36), kept in its range.
#[test]
fn sampled_functions_interpolate_linearly_or_by_cubics() {
let shading = |order: &str| {
let r = read(&format!(
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 0 /Domain [0 1] \
/Range [0 1] /Size [4] /BitsPerSample 8 {order} /DataSource <00ffff00> >> >> shfill"
));
let Some(Paint::Gradient(g)) = all(&r.document).into_iter().find_map(|n| fill(&n).cloned()) else { panic!("no gradient") };
g.gradient.stops.iter().map(|s| (s.offset, s.color.to_rgb_uncalibrated()[0])).collect::<Vec<_>>()
};
let linear = shading("");
assert_eq!(linear.len(), 4, "{linear:?}");
for ((t, v), (et, ev)) in linear.iter().zip([(0.0, 0.0), (1.0 / 3.0, 1.0), (2.0 / 3.0, 1.0), (1.0, 0.0)]) {
assert!((t - et).abs() < 1e-4 && (v - ev).abs() < 1e-3, "{linear:?}");
}
// Sampled evenly; at 3/32 (0.28 of the way from the first sample to the second) the cubic
// through 0, 0, 1 and 1 (the first sample repeated before it) is 0.237, where a straight line
// would be 0.281. Between the two white samples it would overshoot past 1, and is kept at 1.
let cubic = shading("/Order 3");
let at = |t: f32| cubic.iter().find(|s| (s.0 - t).abs() < 1e-4).map(|s| s.1);
assert!(at(3.0 / 32.0).is_some_and(|v| (v - 0.237).abs() < 0.01), "{cubic:?}");
assert!(cubic.iter().all(|s| (0.0..=1.0).contains(&s.1)), "{cubic:?}");
assert!(cubic.iter().any(|s| s.1 == 1.0), "{cubic:?}");
}
@@ -358,6 +358,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
let top = ui.cursor().top();
ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(8.0);
form::caption(ui, tl!("Document Name"));
ui.horizontal(|ui| {
form::text(ui, d, "name", DETAILS - 50.0);
save_button(app, ui, d);
@@ -876,6 +877,44 @@ mod tests {
assert!(selected(&out), "the turned preset stays chosen: {:?}", field(&app, "preset"));
}
/// The document's name field has a caption of its own under the Preset Details heading, and
/// the column stays inside the dialog's height with Save Preset and Advanced Options open.
#[test]
fn the_name_field_is_captioned_and_the_column_fits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
set(&mut app, "advanced", json!(true));
set(&mut app, super::SAVING, json!(true));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp| {
time += 0.5;
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app);
let out = run(&mut app);
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut texts = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
let at = |name: &str| texts.iter().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("{name} is missing: {texts:?}"));
let (heading, caption, value) = (at("PRESET DETAILS"), at("Document Name"), at("Untitled-1"));
assert!(heading.bottom() <= caption.top() && caption.bottom() <= value.top(), "{heading:?} {caption:?} {value:?}");
assert!(value.bottom() <= at("Preset Name").top(), "the preset's own name field follows");
let used = at("Create").bottom() - heading.top();
assert!(used <= super::HEIGHT, "the column used {used}");
}
#[test]
fn a_preset_fills_the_details_and_create_makes_the_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+45 -5
View File
@@ -1418,13 +1418,53 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
crate::panels::panel_menu_items(self.app, ui, id);
}
fn group_header_width(&self, _panels: &[String], _active: &String, _floating: bool, _strip_width: f32) -> f32 {
28.0
fn group_header_width(&self, panels: &[String], _active: &String, floating: bool, _strip_width: f32) -> f32 {
let flyout = self.app.ui.open_panel.as_ref().is_some_and(|panel| panels.contains(panel));
if floating || flyout { 52.0 } else { 28.0 }
}
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, _floating: bool) {
let rect = egui::Rect::from_center_size(ui.max_rect().center(), egui::vec2(16.0, 16.0));
crate::panels::panel_menu(self.app, ui, active, rect);
fn group_header(&mut self, ui: &mut egui::Ui, panels: &[String], active: &String, floating: bool) {
let header = ui.max_rect();
let flyout = self.app.ui.open_panel.clone().filter(|panel| panels.contains(panel));
let menu_cell = if floating || flyout.is_some() {
let split = (header.right() - 28.0).max(header.left());
let cell = egui::Rect::from_min_max(header.min, egui::pos2(split, header.bottom()));
if cell.is_positive() {
let rect = egui::Rect::from_center_size(cell.center(), egui::vec2(24.0_f32.min(cell.width()), 24.0_f32.min(cell.height())));
let tokens = crate::theme::Tokens::get(ui.ctx());
let response = ui.interact(rect, ui.scope_id().with(("collapse-to-icons", active)), egui::Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Collapse to Icons")));
let color = if response.hovered() || response.has_focus() { tokens.text_strong } else { tokens.text_dim };
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).text(
rect.center(),
egui::Align2::CENTER_CENTER,
"»",
egui::TextStyle::Button.resolve(ui.style()),
color,
);
if response.on_hover_text(tl!("Collapse to Icons")).clicked() {
let result = if let Some(panel) = flyout {
// The opening icon can differ from the currently active tab.
self.app.run("window.panel", json!({"panel": panel}))
} else {
self.app.run("window.panel.dock", json!({"panel": active, "icon": true, "group": true}))
};
match result {
// Dock commands reveal their destination. This control leaves the group collapsed.
Ok(_) => self.app.ui.open_panel = None,
Err(error) => self.app.ui.status = error,
}
}
}
egui::Rect::from_min_max(egui::pos2(split, header.top()), header.max)
} else {
header
};
if menu_cell.is_positive() {
let rect =
egui::Rect::from_center_size(menu_cell.center(), egui::vec2(16.0_f32.min(menu_cell.width()), 16.0_f32.min(menu_cell.height())));
crate::panels::panel_menu(self.app, ui, active, rect);
}
}
}
@@ -25,6 +25,7 @@ struct Window {
frames: usize,
/// The texts its last frame drew, with their rects in the window.
texts: Vec<(String, Rect)>,
accessibility: Vec<egui::accesskit::Node>,
}
struct Host {
@@ -82,7 +83,14 @@ impl Host {
let mut host = host.borrow_mut();
let builder = &viewport.builder;
let rect = Rect::from_min_size(builder.position.unwrap_or_default(), builder.inner_size.unwrap_or(vec2(300.0, 200.0)));
host.windows.entry(id).or_insert_with(|| Window { rect, events: Vec::new(), dropped: Vec::new(), frames: 0, texts: Vec::new() });
host.windows.entry(id).or_insert_with(|| Window {
rect,
events: Vec::new(),
dropped: Vec::new(),
frames: 0,
texts: Vec::new(),
accessibility: Vec::new(),
});
let viewports = host.infos();
let (time, focused) = (host.time, host.focus == id);
let Some(window) = host.windows.get_mut(&id) else { return };
@@ -171,10 +179,13 @@ impl Host {
}
let mut shown = Vec::new();
full.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
let accessibility =
full.platform_output.accesskit_update.take().map(|update| update.nodes.into_iter().map(|(_, node)| node).collect()).unwrap_or_default();
let mut host = host.borrow_mut();
host.child_layer_order.insert(id, layer_order);
if let Some(window) = host.windows.get_mut(&id) {
window.texts = shown;
window.accessibility = accessibility;
}
host.apply(&full);
}
@@ -349,6 +360,22 @@ fn open_button_menu(h: &mut Harness, viewport: ViewportId, side: f32, rank: impl
menu
}
/// Click a named button, independent of other controls sharing its size or position.
fn open_named_button_menu(h: &mut Harness, viewport: ViewportId, name: &str) -> Pos2 {
let buttons: Vec<Rect> = h.host.borrow().windows[&viewport]
.accessibility
.iter()
.filter(|node| node.role() == egui::accesskit::Role::Button && node.label() == Some(name))
.filter_map(|node| node.bounds())
.map(|bounds| Rect::from_min_max(pos2(bounds.x0 as f32, bounds.y0 as f32), pos2(bounds.x1 as f32, bounds.y1 as f32)))
.collect();
assert_eq!(buttons.len(), 1, "one visible button named {name}");
let at = buttons[0].center();
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(at), button(at, true), button(at, false)]);
h.frames(8);
at
}
/// A click on the ≡ button in the OS window `viewport`, the 16 pt square at the right end of the
/// panel's tab strip. Returns where the button is.
fn open_panel_menu(h: &mut Harness, viewport: ViewportId) -> Pos2 {
@@ -421,14 +448,18 @@ fn the_library_submenu_scrolls_inside_a_short_native_window() {
assert_submenu_reaches_its_end(&mut h, "swatches", 240.0, "Open Swatch Library", "Default Swatches", "Other Library…");
}
/// The Layers panel's Filter popup, under the topmost 24 pt button: the wheel brings its last row
/// The Layers panel's named Filter button opens its popup: the wheel brings its last row
/// into a short window.
#[test]
fn the_layers_filter_popup_scrolls_inside_a_short_native_window() {
let mut h = Harness::new();
h.ctx.enable_accesskit();
let viewport = h.float("layers");
resize_window(&mut h, viewport, 160.0);
open_button_menu(&mut h, viewport, 24.0, |at| -at.y);
// The header's collapse control is also 24 pt square and sits above Filter.
assert!(text_rect(&h, viewport, "»").is_some(), "keep the competing header control in this regression");
open_named_button_menu(&mut h, viewport, "Filter");
assert!(floating_window(&layout_of(&h.app.ui), "layers").is_some(), "Filter must not collapse the panel");
let over = text_rect(&h, viewport, "By Object").expect("the popup's By Object heading");
assert!(text_rect(&h, viewport, "Hidden").is_none_or(|end| end.bottom() > 160.0), "the popup opens at the top of its list");
wheel_down(&mut h, viewport, over.center(), 5000.0);
@@ -1542,3 +1573,31 @@ fn wheel_without_a_move_uses_the_floating_viewports_pointer() {
h.frame(vec![]);
assert_ne!(h.app.run("text.fonts", json!({})).unwrap(), before);
}
#[test]
fn floating_collapse_arrow_in_a_native_window_moves_the_group_to_the_rail() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
h.frames(3);
let viewport = h.viewport("layers");
h.host.borrow_mut().focus = viewport;
let arrow = h.host.borrow().windows[&viewport]
.texts
.iter()
.find(|(text, _)| text == "»")
.map(|(_, rect)| rect.center())
.expect("the native floating header paints a collapse arrow");
for pressed in [true, false] {
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(arrow), button(arrow, pressed)]);
h.frames(1);
}
h.frames(3);
let layout = layout_of(&h.app.ui);
assert!(floating_window(&layout, "layers").is_none());
assert_eq!(group_of(&layout, &"layers".into()), ["properties", "layers", "libraries"]);
assert!(["properties", "layers", "libraries"].iter().all(|p| in_iconic_column(&layout, p)));
let frames = h.host.borrow().windows[&viewport].frames;
h.frames(3);
assert_eq!(h.host.borrow().windows[&viewport].frames, frames, "the emptied palette is no longer rendered");
}
+115
View File
@@ -1036,3 +1036,118 @@ fn other_function_keys_and_keyboard_guards_keep_their_routes() {
assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document");
std::fs::remove_dir_all(dir).unwrap();
}
fn collapse_to_icons_button(h: &Harness, panel: &str) -> Option<Rect> {
let panel = panel.to_string();
h.ctx.read_response(area_id().with(("header", &panel)).with(("collapse-to-icons", &panel))).map(|r| r.rect)
}
#[test]
fn floating_collapse_arrow_moves_the_whole_group_into_the_icon_rail() {
let mut h = Harness::new();
assert!(collapse_to_icons_button(&h, "properties").is_none(), "docked headers have no extra button");
h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let before = h.layout();
let members = group_of(&before, &"layers".into());
let columns = end_columns(&before).len();
let arrow = collapse_to_icons_button(&h, "layers").expect("floating header offers collapse to icons");
assert!(arrow.width() <= 24.0 && arrow.height() <= 24.0, "a compact header control: {arrow:?}");
h.click(arrow.center());
let layout = h.layout();
assert!(floating_window(&layout, "layers").is_none(), "the old floating window is removed");
assert_eq!(group_of(&layout, &"layers".into()), members, "all tabs move together");
assert!(members.iter().all(|p| in_iconic_column(&layout, p)));
assert_eq!(end_columns(&layout).len(), columns, "reuse the existing icon rail");
assert!(h.app.ui.open_panel.is_none(), "the group stays collapsed until its icon is clicked");
let icon = h.hit(&Hit::Icon("layers".into())).expect("Layers now has a rail icon");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
assert!(h.tab("layers").is_some(), "the collapsed group can reopen as a flyout");
assert!(collapse_to_icons_button(&h, "layers").is_some(), "the reopened flyout also offers collapse to icons");
}
#[test]
fn floating_collapse_arrow_leaves_other_floating_groups_alone() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
h.app.run("window.panel.float", json!({"panel": "color", "below": "layers"})).unwrap();
for _ in 0..3 {
h.frame(vec![]);
}
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group has its own arrow");
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "color").is_some(), "another group in the same window stays floating");
assert!(!in_iconic_column(&layout, "color"));
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn floating_collapse_arrow_creates_a_rail_when_none_exists_and_keeps_the_menu() {
let mut h = Harness::new();
h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap();
let mut layout = h.layout();
if let Some(Node::Split { second, .. }) = layout.root.as_mut()
&& let Node::Dock { columns, .. } = second.as_mut()
{
columns.retain(|column| !column.iconic);
}
assert!(icon_column(&end_columns(&layout)).is_none());
h.app.ui.docking = Some(layout);
for _ in 0..3 {
h.frame(vec![]);
}
let panel = "layers".to_string();
let menu =
h.ctx.read_response(area_id().with(("header", &panel)).with(("panel-menu", &panel))).expect("the existing panel menu remains available").rect;
let arrow = collapse_to_icons_button(&h, "layers").expect("floating group offers collapse");
assert!(!menu.intersects(arrow), "menu and arrow have separate hit targets");
h.click(menu.center());
assert!(egui::Popup::is_any_open(&h.ctx), "the existing menu still opens");
h.press(egui::Key::Escape, egui::Modifiers::NONE);
h.click(arrow.center());
let layout = h.layout();
assert!(in_iconic_column(&layout, "layers"));
assert!(floating_window(&layout, "layers").is_none());
assert!(h.app.ui.open_panel.is_none(), "collapse does not immediately open a flyout");
assert_eq!(main_panels(&layout), ["properties", "libraries"]);
}
#[test]
fn rail_flyout_collapse_arrow_folds_the_panel_back_without_moving_it() {
let mut h = Harness::new();
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "swatches").expect("opened rail panels offer collapse to icons");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "folding a flyout leaves the dock layout intact");
let icon = h.hit(&Hit::Icon("swatches".into())).expect("the rail icon stays available");
h.click(icon.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
assert!(h.tab("swatches").is_some());
}
#[test]
fn rail_flyout_collapse_arrow_closes_the_opening_icon_after_switching_tabs() {
let mut h = Harness::new();
h.app.ui.docking = Some(h.layout());
let icon = h.hit(&Hit::Icon("swatches".into())).unwrap();
h.click(icon.center());
let brushes = h.tab("brushes").unwrap();
h.click(brushes.center());
assert_eq!(h.app.ui.open_panel.as_deref(), Some("swatches"));
let before = h.layout();
let arrow = collapse_to_icons_button(&h, "brushes").expect("the active tab has a collapse arrow");
h.click(arrow.center());
assert!(h.app.ui.open_panel.is_none());
assert!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()).is_none());
assert_eq!(h.layout(), before, "all tabs and their active state stay in the rail");
}
+15 -5
View File
@@ -242,6 +242,7 @@ fn anchor_delete_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Ve
out.textures_delta.clear();
}
/// Real canvas picking and both deletion keys split an ordinary path, then clear every survivor.
#[test]
fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() {
for key in [egui::Key::Delete, egui::Key::Backspace] {
@@ -279,15 +280,23 @@ fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() {
anchor_delete_frame(&mut app, &ctx, vec![press(true)]);
anchor_delete_frame(&mut app, &ctx, vec![press(false)]);
let st = app.session.doc().unwrap();
let path = st.doc.node(id).unwrap().path_data().unwrap();
let points: Vec<Vec<_>> = path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p).collect()).collect();
let pieces = st.selection.objects.clone();
assert_eq!(pieces.len(), 2);
assert_eq!(pieces[0], id);
let points: Vec<Vec<_>> = pieces
.iter()
.map(|piece| {
let path = st.doc.node(*piece).unwrap().path_data().unwrap();
assert_eq!(path.subpaths.len(), 1);
assert!(!path.subpaths[0].closed);
path.subpaths[0].anchors.iter().map(|a| a.p).collect()
})
.collect();
assert_eq!(
points,
vec![vec![Point::new(50.0, 100.0), Point::new(100.0, 80.0)], vec![Point::new(200.0, 120.0), Point::new(250.0, 100.0)]],
"{key:?} must not join the gap"
);
assert!(path.subpaths.iter().all(|sp| !sp.closed));
assert_eq!(st.selection.objects, vec![id]);
assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty());
assert_eq!(st.history.undo.len(), undo + 1);
let after = st.doc.clone();
@@ -296,8 +305,9 @@ fn open_anchor_delete_and_backspace_cut_the_clicked_anchor_then_clear() {
assert_eq!(app.session.doc().unwrap().selection, selection);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
assert_eq!(app.session.doc().unwrap().selection.objects, pieces);
anchor_delete_frame(&mut app, &ctx, vec![press(true)]);
anchor_delete_frame(&mut app, &ctx, vec![press(false)]);
assert!(app.session.doc().unwrap().doc.node(id).is_none(), "the next {key:?} clears the whole remainder");
assert!(pieces.iter().all(|piece| app.session.doc().unwrap().doc.node(*piece).is_none()), "the next {key:?} clears every piece");
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -281,7 +281,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
@@ -321,6 +321,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. |
| 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged |
| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. |
| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. |
+8 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -40,6 +40,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. |
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
@@ -113,6 +115,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G1 Interaction fidelity
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged.
- **Done (part of #4028):** Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker, as the [public page](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/assign-new-colors-to-selected-artwork.html) describes. OK sets the row's new color in the model it was picked in, so a CMYK mix is no longer a hex value converted back; Cancel leaves the row. A Lab new color is kept, but can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke. **Missing:** Lab entry in the picker, swatch libraries in the picker and the row's right-click menu (Pick from Swatches, Limit to Library).
@@ -306,6 +310,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, deletion only (#3380):** deleting anchors or segments that disconnect an ordinary contour creates independent selected path objects, preserving appearance, baked transforms, sibling order and one-step Undo/Redo. Existing multi-subpath paths and compound members stay intact. A second Delete removes all selected survivors. Bounds-fitted gradients keep their original placement on the new objects; already placed gradients stay unchanged. Segment endpoint highlighting remains open; broader G11 scope, scores and estimates are unchanged. Evidence: `crates/engine/tests/it/delete_path_pieces.rs` and the Delete/Backspace frame regression in `crates/ui-egui/src/tests/removeanchors.rs`.
- **Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling. Broader scope and estimates unchanged.
- **Implemented, Windows native canvas and regression checks passed in PR #2643 (#2544):** Anchor Point edits selected blend spine points and handles with path-compatible selection/stacking, handle visibility and 45-degree constraints; #2425 perpendicular segment reshaping is preserved. Broader G11 scope and estimates unchanged.
@@ -831,6 +837,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. |
| 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. |
+6 -5
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -43,6 +43,8 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. |
| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. |
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. |
@@ -129,10 +131,8 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); 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; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); 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; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); 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; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); 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; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 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; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); 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 (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), 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; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); 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; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ 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), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), 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 (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and 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); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); 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 (Shift-double-click enables adjoining adjustment in Width Point Edit), 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; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
@@ -153,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
@@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged |
| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. |
+9 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -42,6 +42,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. |
| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. |
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. |
@@ -199,7 +201,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% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) |
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
@@ -233,7 +235,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated |
| Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged |
| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged |
| Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated |
| Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) |
| **Feature depth** | **105** | **~75%** | | **~250–420** | |
@@ -539,6 +541,9 @@ since 2026-10-01.
## Evidence: what ships today, by area
- **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged.
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.
- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab.
@@ -1886,6 +1891,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. |
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. |
| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. |
+8 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -44,6 +44,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) |
| 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged |
@@ -174,6 +176,7 @@ tools, never checked side by side) isn't captured row by row.
| Behaviour | Status | Notes |
|---|---|---|
| Deleting segments or anchors that disconnect an ordinary path creates independent selected objects | done | #3380, deletion only: first piece retains its ID, styles and transforms carry over in sibling order, bounds-fitted gradients keep their original placement, and one Undo restores the original. Existing multi-subpath paths and compound members stay intact. `delete_path_pieces` covers transformed/styled rectangles, cubic handles and Undo/Redo. Endpoint highlighting remains open. |
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
| Align selected anchors to their bounds or the artboard; repeating an alignment that moves nothing keeps Undo, Redo and saved state | done | #1180 follow-up; engine regressions include rounding-only moves and unchanged handles; #1641 (fixes #1636): Align and Distribute act on the anchors selected with Direct Selection, the points of one path aligning to each other and Distribute spacing them |
| Align selected anchors to a key anchor | missing | #1636 |
@@ -190,7 +193,7 @@ tools, never checked side by side) isn't captured row by row.
| Drag a handle with Shift (45° about its anchor), Alt (move one handle alone) | done | handles snap to smart guides; handle and anchor drags no longer stall when the pointer leaves and re-enters the window, and a move in the release's frame lands (#834) |
| 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 | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor |
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; #3380 (deletion only): disconnected survivors of an ordinary contour become independent selected sibling objects, while multi-subpath paths and compound members stay intact; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes all selected surviving objects; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the surviving objects selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor |
| Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 |
| Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path | done | #2441; Shift-click on a selected member deselects it on release |
| Direct Selection, Group Selection and the Selection tool take a clipping path (a clip group's, or a layer's clipping mask) on its outline, within the selection tolerance, even where the art it clips lies over its edge | done | `HitOptions::clip_outlines`; Select Behind lists the same; the eyedropper and the tools that act on what is drawn take the art |
@@ -341,6 +344,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
## Panels, Control bar, Properties, context menus
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
| Interaction | Status | Evidence |
|---|---|---|
| Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate |
@@ -488,6 +493,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged |