diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f4b2d21d2..d4d68df37 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -770,6 +770,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { 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); @@ -803,11 +804,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { 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, *id, subpaths)?); } } + pieces.finish(d)?; selection.set(survivors); selection.anchors.clear(); selection.segments.clear(); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..035e25686 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1220,40 +1220,40 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { if d.node(*id).is_none() { continue; } - let remove_node = { - let path = path_mut(d, *id)?; + let subpaths: Vec = { + let path = d.node(*id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(*id))?; if let Some(refs) = segments.get(id) { // A segment pick cuts its connection, keeping every neighboring cubic. - path.subpaths = std::mem::take(&mut path.subpaths) - .into_iter() + path.subpaths + .iter() + .cloned() .enumerate() .flat_map(|(si, sp)| { let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); 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) - .into_iter() + path.subpaths + .iter() + .cloned() .enumerate() .flat_map(|(si, sp)| { let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); 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, *id, subpaths)?); } } emptied.finish(d)?; @@ -1263,6 +1263,18 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// Deletion splits an ordinary contour into sibling objects. Existing multi-subpath paths and +/// explicit compound members keep their compound semantics, even when a member has one contour. +pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, id: NodeId, subpaths: Vec) -> Result> { + let single = d.node(id).and_then(|n| n.path_data()).is_some_and(|p| p.subpaths.len() == 1); + if single && compound_or_self(d, id) == id { + 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) -> Vec { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 1af4ac40f..8bc30723a 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -3358,16 +3358,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::>(), indices.iter().map(|&i| original.anchors[i].p).collect::>()); 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()); @@ -3503,13 +3506,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(); @@ -3518,7 +3525,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); } } diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index d34b34293..1a8387317 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -772,16 +772,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 +804,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(); } diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs new file mode 100644 index 000000000..e57e39d61 --- /dev/null +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -0,0 +1,142 @@ +//! Original synthetic geometry for the deletion half of #3380; no external fixtures. + +use serde_json::json; +use vectorcraft_doc::{Appearance, NodeId, NodeKind}; +use vectorcraft_engine::Session; +use vectorcraft_geom::{Affine, Anchor, PathData, SubPath}; +use vectorcraft_testkit::{invariants::check_session, raster::render_artboard}; + +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()) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +#[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()); + } +} + +#[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(); +} + +#[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); + 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 { + let extra = s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].clone(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap().subpaths.push(extra); + 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(); + 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(other).unwrap(), &sibling); + assert_eq!(st.selection.objects, [id]); + check_session(&s).unwrap(); + } + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f17d27e4d..f54cfcfea 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/crates/ui-egui/src/tests/removeanchors.rs b/crates/ui-egui/src/tests/removeanchors.rs index a809f174d..79d37782b 100644 --- a/crates/ui-egui/src/tests/removeanchors.rs +++ b/crates/ui-egui/src/tests/removeanchors.rs @@ -279,15 +279,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> = 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> = 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 +304,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"); } } diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..bbe5b19da 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Gaps +> **Last updated:** 2026-11-07 · **Change:** #3380 deletion only: disconnected survivors of an ordinary single-contour path become selected sibling objects; existing multi-subpath paths and compound members stay intact. Segment endpoint highlighting remains open. Scores and estimates unchanged. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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 @@ -37,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 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 | 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 | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..ea8463eb8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap: milestones and what's next +> **Last updated:** 2026-11-07 · **Change:** #3380 deletion only: disconnected survivors of an ordinary single-contour path become selected sibling objects; existing multi-subpath paths and compound members stay intact. Segment endpoint highlighting remains open. Scores and estimates unchanged. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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 @@ -36,6 +38,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 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 | 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. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -119,7 +122,7 @@ 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 | 12–18 | +| M2 | Drawing tools + smart guides | #3380 deletion creates independent selected path pieces (endpoint highlighting remains open); ✅ 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 | 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 | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..de8e06dc3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,7 @@ # Parity with Adobe Illustrator +> **Last updated:** 2026-11-07 · **Change:** #3380 deletion only: disconnected survivors of an ordinary single-contour path become selected sibling objects; existing multi-subpath paths and compound members stay intact. Segment endpoint highlighting remains open. Scores and estimates unchanged. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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) ## Feature submission evidence @@ -31,6 +33,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 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 | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; 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-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | @@ -179,7 +182,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 (#3380, deletion only; `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 | | 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 | @@ -448,7 +451,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding (Curvature point display improved, #1236 in part) | 8–13 | | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done; no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | -| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | +| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects (#3380, deletion only; `tests/it/delete_path_pieces.rs`); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4efbee4aa..b4dbee4f1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,7 @@ # UI and interaction parity +> **Last updated:** 2026-11-07 · **Change:** #3380 deletion only: disconnected survivors of an ordinary single-contour path become selected sibling objects; existing multi-subpath paths and compound members stay intact. Segment endpoint highlighting remains open. Scores and estimates unchanged. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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 @@ -39,6 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 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-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 | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color follows the hex and a double-click opens the Color Picker over the dialog, whose OK fills Color (#5364); scores and estimates unchanged | @@ -167,6 +170,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, 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 |