From b6cf45737f10ceb83374bd372260ca3c63d56525 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:22:47 -0500 Subject: [PATCH 01/11] EPS import: sampled functions interpolate between their samples A sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading over one came in as flat blocks. It is now interpolated multilinearly, and with Order 3 by cubics through the samples, kept in its range (PLRM 3rd ed. section 3.10.1). An axial or radial shading over a linearly interpolated sampled function of one input has a stop at each sample. --- crates/eps/src/import/graphics/paint.rs | 21 ++++++- crates/eps/src/import/shading.rs | 75 +++++++++++++++++++---- crates/eps/src/import/tests_generators.rs | 32 ++++++++++ docs/file-format-parity.md | 5 +- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index 8b0a729ee..fc2490fb4 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -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 = 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> { 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 { + 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 diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index c59881a98..f2ab9611f 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -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> { let get = |k: &str| f.borrow().get(&Key::name(k)).cloned(); let nums = @@ -360,17 +363,65 @@ 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::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. + vec![(p.round() as usize, 1.0)] + }; + combos = combos.saturating_mul(w.len()); + taps.push(w); } - Ok((0..outs).map(|j| sample(index, j)).collect()) + 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] +} diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index 4337b9474..eb2d756fd 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -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::>() + }; + 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:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..12e42ac80 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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: sampled functions interpolate between their samples; 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 @@ -153,7 +153,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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); 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); 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); 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; 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 ``, 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 | @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 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-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From e6a899857fc964c05f21acf9058e42e9c1985686 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:20:14 +0530 Subject: [PATCH 02/11] M2.1: split disconnected path pieces after deletion (#3380) --- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/path.rs | 38 +++-- crates/engine/src/tests/draw2.rs | 25 +-- .../engine/tests/it/copy_direct_selection.rs | 18 ++- crates/engine/tests/it/delete_path_pieces.rs | 142 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/tests/removeanchors.rs | 19 ++- docs/gaps.md | 3 + docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 4 + 11 files changed, 229 insertions(+), 39 deletions(-) create mode 100644 crates/engine/tests/it/delete_path_pieces.rs 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<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); @@ -803,11 +804,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, *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<Value> { if d.node(*id).is_none() { continue; } - let remove_node = { - let path = path_mut(d, *id)?; + let subpaths: Vec<SubPath> = { + 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<Value> { 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<SubPath>) -> Result<Vec<NodeId>> { + 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<usize>) -> Vec<SubPath> { 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::<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()); @@ -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<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 +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 | From 9c8eef4111fe53df11eb9cfd3c2b1faa423a08d4 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:24:15 -0500 Subject: [PATCH 03/11] EPS import: a sampled function's sample reads count against the operation budget Each evaluation of a sampled function copied its DataSource and read every tap combination without counting that work: a shading evaluates its function once per stop, so a long DataSource or many interpolated inputs did far more work than the budget charged. Both now count. The nearest-sample fallback is kept at the last sample, which a fractional Size could put it past. --- crates/eps/src/import/shading.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index f2ab9611f..54c8bd006 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -338,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 { @@ -380,12 +382,15 @@ impl Interp<'_> { } 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. - vec![(p.round() as usize, 1.0)] + // 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); } + // 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 { From c232919dd3234bbbf468d186790a70a4eb186ceb Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:02:31 +0530 Subject: [PATCH 04/11] M2.1: clarify deletion evidence and function documentation (#3380) --- crates/engine/src/cmd/edit.rs | 2 ++ crates/engine/src/cmd/path.rs | 2 ++ crates/engine/src/tests/draw2.rs | 2 ++ crates/engine/tests/it/copy_direct_selection.rs | 1 + crates/engine/tests/it/delete_path_pieces.rs | 5 +++++ crates/ui-egui/src/tests/removeanchors.rs | 1 + docs/gaps.md | 6 ++++-- docs/roadmap.md | 8 +++----- docs/target-app-parity.md | 10 ++++++---- docs/ui-parity.md | 6 +++--- 10 files changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d4d68df37..9dc17b7b6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -762,6 +762,8 @@ 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. fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> { let st = s.doc()?; let sel = st.selection.clone(); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 035e25686..fbe5fdf83 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1189,6 +1189,8 @@ 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. 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")?; diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 8bc30723a..c74f199a9 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -3331,6 +3331,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 = [ @@ -3469,6 +3470,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; diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index 1a8387317..957d77829 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -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(); diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index e57e39d61..d856b35da 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -6,16 +6,19 @@ use vectorcraft_engine::Session; use vectorcraft_geom::{Affine, Anchor, PathData, 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"] { @@ -79,6 +82,7 @@ fn deleting_opposite_rectangle_segments_makes_independent_styled_objects_and_und } } +/// 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(); @@ -112,6 +116,7 @@ fn deleting_separated_anchors_splits_an_open_path_and_keeps_cubic_handles() { check_session(&s).unwrap(); } +/// Existing multi-subpath paths and explicit compound members retain their object structure. #[test] fn deletion_preserves_intentional_multi_subpaths_and_compound_members() { for command in ["edit.clear", "edit.cut"] { diff --git a/crates/ui-egui/src/tests/removeanchors.rs b/crates/ui-egui/src/tests/removeanchors.rs index 79d37782b..44bf492cc 100644 --- a/crates/ui-egui/src/tests/removeanchors.rs +++ b/crates/ui-egui/src/tests/removeanchors.rs @@ -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] { diff --git a/docs/gaps.md b/docs/gaps.md index bbe5b19da..949e9da32 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # 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 updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 @@ -39,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | @@ -300,6 +300,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. 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index ea8463eb8..804b96aba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,6 @@ # 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 updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 @@ -38,6 +37,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | @@ -122,10 +122,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 | #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 | +| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces, with endpoint highlighting still 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 | 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) | ✅ 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 | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index de8e06dc3..bd2a676ab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,7 +1,6 @@ # 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 updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 @@ -33,6 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | @@ -182,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); 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`) | +| 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; `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 | | 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 | @@ -451,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); 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 | +| 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 | | 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) | @@ -519,6 +519,8 @@ 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. 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. + - 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. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b4dbee4f1..bf4c70672 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,7 +1,6 @@ # 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 updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 @@ -41,6 +40,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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-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 | @@ -187,7 +187,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 | From 90d4e3d65f0912b9b423d12be55691394f97f61a Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:51:51 +0530 Subject: [PATCH 05/11] M2.1: verify preserved path geometry after deletion (#3380) --- crates/engine/tests/it/delete_path_pieces.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index d856b35da..7730a23f8 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -116,7 +116,7 @@ fn deleting_separated_anchors_splits_an_open_path_and_keeps_cubic_handles() { check_session(&s).unwrap(); } -/// Existing multi-subpath paths and explicit compound members retain their object structure. +/// 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"] { @@ -124,21 +124,33 @@ fn deletion_preserves_intentional_multi_subpaths_and_compound_members() { 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 { - 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); + 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(); From 2dc6991cf318ae2f49f55145f897d9c1f2d61e2d Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:51:00 +0530 Subject: [PATCH 06/11] M2.1: retain gradient placement when deletion splits paths (#3380) --- crates/engine/src/cmd/path.rs | 5 ++ crates/engine/tests/it/delete_path_pieces.rs | 86 +++++++++++++++++++- docs/gaps.md | 5 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 5 +- 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index fbe5fdf83..b8b8a6914 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1267,9 +1267,14 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> { /// 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. +/// Separate objects retain the gradient placement of the original path. pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, id: NodeId, subpaths: Vec<SubPath>) -> Result<Vec<NodeId>> { 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 { + // Pin only actual splits: each new object's smaller bounds would otherwise refit its gradients. + if subpaths.len() > 1 { + d.node_mut(id).ok_or(EngineError::NoNode(id))?.pin_gradients(); + } super::draw2::replace_with_pieces(d, beside, id, subpaths.into_iter().map(PathData::single).collect()) } else { path_mut(d, id)?.subpaths = subpaths; diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index 7730a23f8..0d8b7171f 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -1,9 +1,10 @@ //! 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, SubPath}; +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. @@ -157,3 +158,86 @@ fn deletion_preserves_intentional_multi_subpaths_and_compound_members() { } } } + +/// 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); +} diff --git a/docs/gaps.md b/docs/gaps.md index 949e9da32..097cc6df7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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 @@ -38,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | @@ -300,7 +301,7 @@ 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. 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`. +- **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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 804b96aba..9e7c2428c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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 @@ -37,6 +37,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | @@ -122,7 +123,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 (#3380 deletion creates independent selected path pieces, with endpoint highlighting still 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 | 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 | 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 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), 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 | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bd2a676ab..8d71394fb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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 @@ -32,6 +32,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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 | @@ -182,7 +183,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); deletion splits an ordinary contour into selected sibling objects (#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`) | +| 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 | | 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 | @@ -519,7 +520,7 @@ 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. 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. +- **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. - 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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bf4c70672..46925cf11 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last updated:** 2026-11-08 · **Change:** #3380 documentation review follow-up (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 updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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 @@ -40,6 +40,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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-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) | @@ -170,7 +171,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. | +| 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 | From 39848747282470d7f2fc2a7c8a0be01e0fdd376e Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:35:48 +0530 Subject: [PATCH 07/11] M2.1: avoid discarded path copies during deletion (#3380) --- crates/engine/src/cmd/edit.rs | 20 +++++++----- crates/engine/src/cmd/path.rs | 58 ++++++++++++++++++++++++++--------- docs/gaps.md | 6 ++-- docs/roadmap.md | 6 ++-- docs/target-app-parity.md | 6 ++-- docs/ui-parity.md | 6 ++-- 6 files changed, 64 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9dc17b7b6..c6afc9eaf 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -764,6 +764,10 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> { /// 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(); @@ -780,20 +784,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); @@ -806,7 +812,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> { d.remove(p)?; } } else { - survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, *id, subpaths)?); + survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?); } } pieces.finish(d)?; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b8b8a6914..2c15f7cdd 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -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}; @@ -1191,6 +1192,9 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> { /// 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")?; @@ -1222,13 +1226,14 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> { if d.node(*id).is_none() { continue; } + let original = deletion_source(d, *id)?; let subpaths: Vec<SubPath> = { - let path = d.node(*id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(*id))?; + 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 - .iter() - .cloned() + subpaths + .into_iter() .enumerate() .flat_map(|(si, sp)| { let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); @@ -1239,9 +1244,8 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> { } else { // Remove every selected anchor against the original indexes, keeping surviving // stretches separate rather than joining across deleted anchors. - path.subpaths - .iter() - .cloned() + subpaths + .into_iter() .enumerate() .flat_map(|(si, sp)| { let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); @@ -1255,7 +1259,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> { emptied.put(d, *id, vec![])?; } else { // Every remaining piece is selected whole, so the next Delete removes them. - survivors.extend(replace_deleted_pieces(d, &mut emptied, *id, subpaths)?); + survivors.extend(replace_deleted_pieces(d, &mut emptied, original, subpaths)?); } } emptied.finish(d)?; @@ -1265,15 +1269,39 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> { 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. -/// Separate objects retain the gradient placement of the original path. -pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, id: NodeId, subpaths: Vec<SubPath>) -> Result<Vec<NodeId>> { - let single = d.node(id).and_then(|n| n.path_data()).is_some_and(|p| p.subpaths.len() == 1); +/// 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 { - d.node_mut(id).ok_or(EngineError::NoNode(id))?.pin_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 { diff --git a/docs/gaps.md b/docs/gaps.md index 097cc6df7..772dc9616 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # Gaps -> **Last updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (#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 @@ -38,8 +37,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | -| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e7c2428c..c7dda608e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,6 @@ # Roadmap: milestones and what's next -> **Last updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (#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 @@ -37,8 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | -| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8d71394fb..6fb7c44ac 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,7 +1,6 @@ # Parity with Adobe Illustrator -> **Last updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (#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) ## Feature submission evidence @@ -32,8 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | -| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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 | 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 46925cf11..4092c6310 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,7 +1,6 @@ # UI and interaction parity -> **Last updated:** 2026-11-08 · **Change:** #3380 deletion review follow-up: split survivors keep their original bounds-fitted gradient placement; documentation evidence, geometry assertions and function docs clarified. 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (#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 @@ -40,8 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | #3380, deletion only: pin bounds-fitted gradients before creating separate survivors; placed gradients and connected remainders retain their behavior. Regression coverage checks linear/radial fill and stroke vectors, rendered survivors and Undo/Redo. Scores and estimates unchanged. | -| 2026-11-08 | minor | #3380 review follow-up: clarify deletion evidence and survivor selection, consolidate milestone notes, and retain the open endpoint-highlighting work. Behavior, 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-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 | From 8782c14e59cfc66aa477c5f33ca4b0a23206924d Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:35:23 +0530 Subject: [PATCH 08/11] M2.1: expect a cut Pen segment to leave independent path pieces (#3380) Co-authored-by: Cursor <cursoragent@cursor.com> --- crates/engine/src/tests/draw2.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index d7e4e6cfc..38ba19859 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2698,11 +2698,17 @@ 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(); s.execute("edit.cut", &json!({})).unwrap(); + let pieces = paths(&s); 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])); + assert_eq!(s.clipboard.nodes.len(), 1); + assert_eq!(left, vec![2]); + assert_eq!(pieces.len(), 2); + assert!(pieces.iter().all(|(_, path)| path.subpaths.len() == 1 && path.subpaths[0].anchors.len() == 2)); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); // 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(); From 0e1aa5cc2ea0f0a174cd84f66f09d61eb3c02542 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 17:32:09 -0800 Subject: [PATCH 09/11] Plug-ins: a filter's read-only objects stay in its input and failed decodes count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filter run kept a second copy of every read-only object as JSON values. The run now keeps where each object is in the input, and checks an output entry against it by parsing that one object, unless the entry is `{"id": …}` alone. An image that reaches the decoder now counts toward the 16 MiB pixel total even when decoding fails, so broken images use up the same total as images that decode. The plugin.run text, docs/mcp.md and the progress line state the paint order and that rule, and docs/plugins.md states that a CMYK image's pixels are sRGB. New tests cover the per-image 1 MiB, the 16 MiB pixel total and the 16 MiB text total at and one past their values, memory-budget failures before the plug-in starts, images that are not decoded, CMYK images included, and leading per run. --- crates/engine/src/cmd/plugin.rs | 11 +- crates/engine/src/tests/plugins.rs | 63 ++++++++ crates/plugins/src/objects.rs | 118 +++++++++------ crates/plugins/src/objects/tests.rs | 223 +++++++++++++++++++++++++++- docs/mcp.md | 11 +- docs/plugins.md | 11 +- docs/target-app-parity.md | 2 +- 7 files changed, 365 insertions(+), 74 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index c2736d74d..4338cdde9 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -41,7 +41,6 @@ //! images or symbol instances of those kinds, to read only: its output must return each of them //! unchanged ([`objects::decode_output_with`]). -use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; use serde_json::{Map, Value, json}; @@ -134,9 +133,9 @@ pub struct FilterRun { params: Vec<u8>, /// The objects the plug-in is given, and the container new objects go in. inputs: Vec<NodeId>, - /// The type, images and symbol instances among them (a filter that asks for them), as given: - /// its output must return each one unchanged. - read_only: HashMap<NodeId, Value>, + /// The type, images and symbol instances among them (a filter that asks for them): its output + /// must return each one as `input` gives it. These are their places in `input`. + read_only: objects::ReadOnlySpans, parent: Option<NodeId>, /// The document it was made from, and that document's revision then. document: (u64, u64), @@ -270,7 +269,7 @@ impl FilterRun { fn finish(self, s: &mut Session, output: &[u8]) -> Result<Value> { let m = self.plugin.manifest(); let may = m.permissions(); - let out = objects::decode_output_with(output, &self.read_only).map_err(err)?; + let out = objects::decode_output_with(output, &self.input, &self.read_only).map_err(err)?; if out.document_data.is_some() && !may.contains(&Permission::State) { return Err(not_granted(&m.name, Permission::State)); } @@ -908,7 +907,7 @@ pub fn specs() -> Vec<CommandSpec> { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [object id] (left out or null: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, in paint order from the bottom, with at most 16 MiB of pixels and 16 MiB of type runs per run (an object that doesn't fit what the objects before it left comes with pixelsOmitted or truncated; an image that reaches the decoder counts toward the pixels even when decoding fails); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over), and more than 100,000 of these objects fail it with an object-budget error: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 803db493e..c498751d8 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1603,6 +1603,69 @@ fn read_only_kind_field_and_omission_refuse_without_undo() { assert!(e.contains("`inputs` needs `api` 2"), "{e}"); } +/// A run past its memory budget fails before the plug-in starts, with the document and undo +/// history as they were: a read-only object whose other fields don't fit, a path and a compound +/// path that don't fit, and parameters past the input's half of the cap, with objects selected or +/// with none. The module has one page of memory, the most it may have, so a run that started would +/// report that the plug-in ran out of memory instead. +#[test] +fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + use vectorcraft_geom::FillRule; + use vectorcraft_plugins::{Limits, Plugin, registry}; + let id = "test.engine.inputs.budget"; + let params: Vec<String> = (0..9).map(|i| format!(r#""p{i}":{{"type":"string","maxLength":4096}}"#)).collect(); + let manifest = format!( + r#"{{"id":"{id}","name":"Budget","kind":"filter","api":2,"permissions":["doc.read","doc.write"],"inputs":["type"],"params":{{{}}}}}"#, + params.join(",") + ); + let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap(); + // One page of memory: the input and the parameters share 32 KiB. + registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap()); + let mut s = session(); + let small = rect(&mut s, 10.0, "#ff0000"); + let long = rect(&mut s, 30.0, "#00ff00"); + let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Hi"}))["id"].as_u64().unwrap()); + let mut compound = NodeId(0); + s.edit("Set up", |d, _| { + d.node_mut(t).unwrap().name = Some("n".repeat(40_000)); + let points: Vec<Point> = (0..4_000).map(|i| Point::new(f64::from(i), 0.0)).collect(); + let outline = PathData::single(SubPath::polyline(&points, false)); + if let NodeKind::Path { path, .. } = &mut d.node_mut(long).unwrap().kind { + *path = outline.clone(); + } + let member = Node::path(d.alloc_id(), outline, Appearance::default()); + let n = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![Arc::new(member)], rule: FillRule::NonZero }); + let layer = d.layers[0].id; + compound = d.insert(Some(layer), usize::MAX, n).unwrap(); + Ok(()) + }) + .unwrap(); + let padding: Value = (0..9).map(|i| (format!("p{i}"), json!("x".repeat(4096)))).collect::<serde_json::Map<_, _>>().into(); + let too_large = "memory budget (the selection is too large to hand over)"; + let named = format!("memory budget (object {} needs", t.0); + let with_padding = |ids: Vec<u64>| { + let mut p = padding.clone(); + p["id"] = json!(id); + p["ids"] = json!(ids); + p + }; + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + for (p, says) in [ + (json!({"id": id, "ids": [t.0]}), named.as_str()), + (json!({"id": id, "ids": [long.0]}), too_large), + (json!({"id": id, "ids": [compound.0]}), too_large), + (with_padding(vec![small.0]), too_large), + (with_padding(vec![]), too_large), + ] { + let e = s.execute("plugin.run", &p).unwrap_err().to_string(); + assert!(e.contains(says), "{says}: {e}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}"); + } + assert!(registry::remove(id)); +} + /// What the user granted is kept in the plug-in store with the module: the next session installs /// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is /// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index c8dfcb2f0..d0a461962 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -6,6 +6,7 @@ //! in-range coordinates, capped counts, clamped colours) before it reaches the document. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; @@ -46,7 +47,8 @@ pub const MAX_INPUT_RUNS: usize = 4_096; /// image comes with `pixelsOmitted`. pub const MAX_INPUT_PIXELS: usize = 1 << 20; /// Most pixels a filter is given over all the images of one input, in characters of base64: an -/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`. +/// image whose pixels don't fit what the images before it left comes with `pixelsOmitted`, and an +/// image the host starts to decode takes its size from the total even when decoding fails. pub const MAX_INPUT_PIXELS_TOTAL: usize = 16 << 20; /// Most text a filter is given over all the type of one input, in bytes of the JSON of their runs /// and paragraphs: type that doesn't fit what the type before it left comes with `truncated`. @@ -191,12 +193,15 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> { /// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`] /// naming it and its size, a path or compound path that doesn't fit is the limit the runtime gives /// an input past its share, and more than [`MAX_OBJECTS`] read-only objects, more than an output -/// may list, is an [`Error::Limit`] too. Also returns those objects by id, which the filter -/// must return as they were ([`decode_output_with`]). -pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> { +/// may list, is an [`Error::Limit`] too. Also returns where those objects are in the input, which +/// the filter must return as they were ([`decode_output_with`]). +pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec<u8>, ReadOnlySpans)> { encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL }) } +/// Where each read-only object of an input is in the input's bytes. +pub type ReadOnlySpans = HashMap<NodeId, Range<usize>>; + /// The input with no objects; each object adds its JSON and a comma. const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}"; @@ -214,27 +219,23 @@ impl Allowance { const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX }; } -/// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map -/// it returns, and written into the input from there. -fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, HashMap<NodeId, Value>)> { +/// [`encode_input_with`] within `left` of the totals. Each object is held only while it is written +/// into the input. +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec<u8>, ReadOnlySpans)> { let mut read_only = HashMap::new(); let symbols = doc.symbol_boxes(); let mut out = b"{\"objects\":[".to_vec(); let mut first = true; for n in ids.iter().filter_map(|id| doc.node(*id)) { - let path; - let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { + let (o, checked) = match input_kind(n).filter(|k| kinds.contains(k)) { // The output must return every read-only object, and lists at most `MAX_OBJECTS`. Some(_) if read_only.len() >= MAX_OBJECTS => return Err(too_many_read_only()), Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? { - Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true), + Some(o) => (o, true), None => continue, }, None => match encode(n) { - Some(o) => { - path = o; - (&path, false) - } + Some(o) => (o, false), None => continue, }, }; @@ -242,9 +243,13 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut if !std::mem::take(&mut first) { out.push(b','); } - if serde_json::to_writer(&mut out, o).is_err() { + let object = out.len(); + if serde_json::to_writer(&mut out, &o).is_err() { return Ok((Vec::new(), HashMap::new())); } + if checked { + read_only.insert(n.id, object..out.len()); + } let size = out.len() - start; // A path or compound path past what is left fails the run as the runtime would fail it, // before the rest of the selection is written. @@ -291,7 +296,8 @@ fn affine_json(xf: Affine) -> Value { /// - an image (`"image"`): `pixelSize: [w, h]`, `colorMode: "rgb"|"cmyk"|"gray"|"unknown"`, /// `linked`, `pixelsOmitted`, and for an embedded image of at most [`MAX_INPUT_PIXELS`] /// `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top (a CMYK image's inks -/// through the working CMYK profile). A linked image's file and path are never read or given; +/// through the working CMYK profile, as sRGB). A linked image's file and path are never read or +/// given; /// - a symbol instance (`"symbolInstance"`): `symbolId` (the symbol's name), `missing: true` when /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option<Value> { @@ -354,13 +360,13 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left NodeKind::Image(im) => { let blob = doc.image_blob(im); let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY)); + let mut charged = 0; // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max)); + let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max, &mut charged)); + left.pixels = left.pixels.saturating_sub(charged); o["pixelsOmitted"] = json!(pixels.is_none()); if let Some(p) = pixels { - let b64 = craft_vectorcraft_format::base64::encode(&p); - left.pixels = left.pixels.saturating_sub(b64.len()); - o["pixelsBase64"] = json!(b64); + o["pixelsBase64"] = json!(craft_vectorcraft_format::base64::encode(&p)); } } _ => {} @@ -458,20 +464,34 @@ fn color_mode(b: &ImageBlob) -> &'static str { if readable.is_some() { "rgb" } else { "unknown" } } +#[cfg(test)] +thread_local! { + /// How many images [`rgba_pixels`] has started to decode on this thread. + static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; +} + /// The pixels of image `b` as straight RGBA, when it is `w` × `h` pixels and they fit `max` /// characters of base64; the size is checked, and read from the header, before anything is decoded. -fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> { +/// `charged` gets the characters of base64 an image that reaches the decoder takes from the total, +/// which it takes even when decoding fails. +fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize, charged: &mut usize) -> Option<Vec<u8>> { let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?; - if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() { + let cost = bytes.div_ceil(3).saturating_mul(4); + if bytes == 0 || cost > max as u64 || oversized_tiff(&b.bytes).is_some() { return None; } - if b.is_cmyk() { - return cmyk_rgba(b, w, h); - } + let cmyk = b.is_cmyk(); let reader = || image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok(); - if reader()?.into_dimensions().ok()? != (w, h) { + let size = if cmyk { b.header_size()? } else { reader()?.into_dimensions().ok()? }; + if size != (w, h) { return None; } + *charged = cost as usize; + #[cfg(test)] + DECODES.with(|n| n.set(n.get() + 1)); + if cmyk { + return cmyk_rgba(b, w, h); + } let mut r = reader()?; let mut limits = image::Limits::default(); // The decoder's own buffers besides the RGBA it gives. @@ -484,13 +504,10 @@ fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> { /// The inks → XYZ table of [`cmyk_rgba`] and the color settings it was built with. static CMYK_DISPLAY: Mutex<Option<(Arc<Cms>, Arc<CmykLut>)>> = Mutex::new(None); -/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels (checked from its -/// header first): its inks shown through the working CMYK profile, by a table built as -/// `vectorcraft-render`'s `InkLut` builds its own and kept for the active color settings. +/// The pixels of CMYK image `b` as straight RGBA, when it is `w` × `h` pixels: its inks shown +/// through the working CMYK profile, by a table built as `vectorcraft-render`'s `InkLut` builds its +/// own and kept for the active color settings. fn cmyk_rgba(b: &ImageBlob, w: u32, h: u32) -> Option<Vec<u8>> { - if b.header_size()? != (w, h) { - return None; - } let inks = b.cmyk().filter(|i| (i.width, i.height) == (w, h))?; let lut = { let cms = cms::active(); @@ -761,16 +778,18 @@ pub struct Output { /// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` /// when the plug-in declined (the message reaches the user). pub fn decode_output(bytes: &[u8]) -> Result<Output> { - decode_output_with(bytes, &HashMap::new()) + decode_output_with(bytes, &[], &HashMap::new()) } -/// [`decode_output`] for a filter given `read_only` objects ([`encode_input_with`]): it must list -/// each of them exactly once, as `{"id": …}` alone or exactly as it was given (numbers compared by -/// value), and such an entry is [`OutObject::kept`]. Any other entry for one of them, and one left -/// out, is refused naming the object and the first field that differs, before anything changes. -pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> Result<Output> { +/// [`decode_output`] for a filter given `read_only` objects, at those places in `input` +/// ([`encode_input_with`]): it must list each of them exactly once, as `{"id": …}` alone or exactly +/// as it was given (numbers compared by value), and such an entry is [`OutObject::kept`]. Any other +/// entry for one of them, and one left out, is refused naming the object and the first field that +/// differs, before anything changes. Each object is parsed from `input` only to compare it, one at +/// a time, and an object that can't be read from there is an error. +pub fn decode_output_with(bytes: &[u8], input: &[u8], read_only: &ReadOnlySpans) -> Result<Output> { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - let objects = decode_objects(&v, read_only)?; + let objects = decode_objects(&v, input, read_only)?; let document_data = match v.get_mut("documentData").map(Value::take) { None => None, Some(Value::Null) => Some(None), @@ -789,12 +808,12 @@ pub fn decode_output_with(bytes: &[u8], read_only: &HashMap<NodeId, Value>) -> R /// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> { let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - decode_objects(&v, &HashMap::new()) + decode_objects(&v, &[], &HashMap::new()) } /// The objects of output `v`, or its decline; entries for `read_only` objects as /// [`decode_output_with`] reads them. -fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<OutObject>> { +fn decode_objects(v: &Value, input: &[u8], read_only: &ReadOnlySpans) -> Result<Vec<OutObject>> { if let Some(e) = declined(v) { return Err(e); } @@ -808,12 +827,16 @@ fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<O .iter() .enumerate() .map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) { - Some((id, given)) => { + Some((id, span)) => { let at = format!("objects[{i}] (id {})", id.0); if !returned.insert(id) { return Err(out_err(&at, "is listed twice")); } - check_unchanged(o, given, &at)?; + // `{"id": …}` alone keeps the object, so there is nothing to compare it with. + if o.as_object().is_none_or(|m| m.len() != 1) { + let given = serde_json::from_slice(input.get(span.clone()).unwrap_or_default()).map_err(|e| Error::Io(e.to_string()))?; + check_unchanged(o, &given, &at)?; + } Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() }) } None => decode_object(o, &format!("objects[{i}]"), &mut anchors), @@ -836,15 +859,12 @@ fn output_id(o: &Value) -> Option<NodeId> { id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId) } -/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone, -/// or `given` itself, numbers compared by value. Otherwise the error names the first field that -/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as +/// Checks entry `o` of a filter's output for a read-only object it was `given`: `given` itself, +/// numbers compared by value. Otherwise the error names the first field that differs +/// (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as /// [`crate::permissions::display_text`] shows a manifest string, since the entry's keys are the /// plug-in's. fn check_unchanged(o: &Value, given: &Value, at: &str) -> Result<()> { - if o.as_object().is_some_and(|m| m.len() == 1) { - return Ok(()); - } let changed = match (o, given) { (Value::Object(x), Value::Object(y)) => { let mut keys: Vec<&String> = x.keys().collect(); diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 0a7b1d0d1..ba4cccae2 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -481,6 +481,212 @@ fn cmyk_image_pixels_go_through_the_working_cmyk_profile() { assert_ne!(&pixels[..3], &[0, 0, 0], "not converted without the profile"); } +/// The image object for `key` in `d`'s layer, `size` pixels. +fn placed(d: &mut Document, layer: NodeId, key: &str, size: (u32, u32)) -> NodeId { + let n = image_node(d, key, size, None); + d.insert(Some(layer), usize::MAX, n).unwrap() +} + +/// The largest pixels one image may have fill exactly [`MAX_INPUT_PIXELS`] characters of base64 +/// (512 × 384); one pixel more doesn't fit. +#[test] +fn an_image_at_the_limit_is_given_and_one_pixel_more_is_omitted() { + let (mut d, _, layer, _) = mixed(); + for ((w, h), omitted) in [((512, 384), false), ((1, 196_609), true)] { + d.images.insert("limit".into(), ImageBlob::png(png(w, h))); + let id = placed(&mut d, layer, "limit", (w, h)); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!(o["pixelsOmitted"], json!(omitted), "{w} × {h}"); + assert_eq!(o.get("pixelsBase64").and_then(Value::as_str).map(str::len), (!omitted).then_some(MAX_INPUT_PIXELS), "{w} × {h}"); + } +} + +/// Sixteen images at the per-image limit fill [`MAX_INPUT_PIXELS_TOTAL`] exactly and are all given; +/// the next one in paint order, even a small one, comes with `pixelsOmitted`. +#[test] +fn images_fill_the_pixel_total_and_the_next_is_omitted() { + let (mut d, _, layer, [_, embedded, ..]) = mixed(); + d.images.insert("limit".into(), ImageBlob::png(png(512, 384))); + let mut ids: Vec<NodeId> = (0..16).map(|_| placed(&mut d, layer, "limit", (512, 384))).collect(); + ids.push(embedded); + let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Image], usize::MAX).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let objects = v["objects"].as_array().unwrap(); + assert_eq!(objects.len(), 17); + let given: usize = objects.iter().filter_map(|o| o.get("pixelsBase64")?.as_str()).map(str::len).sum(); + assert_eq!(given, MAX_INPUT_PIXELS_TOTAL); + let omitted: Vec<bool> = objects.iter().map(|o| o["pixelsOmitted"] == json!(true)).collect(); + assert_eq!(omitted, [vec![false; 16], vec![true]].concat()); +} + +/// Type whose runs and paragraphs together fill [`MAX_INPUT_TEXT_TOTAL`] exactly is all given, and +/// type after it in paint order comes with `truncated`. With one byte more, the last type that +/// doesn't fit comes with `truncated` and what it leaves still goes to the type after it. +#[test] +fn type_fills_the_text_total_and_what_follows_is_truncated() { + use vectorcraft_doc::{CharStyle, TextObject}; + let (mut d, _, layer, _) = mixed(); + let place = |d: &mut Document, len: usize| { + let t = TextObject::point(Point::new(0.0, 0.0), &"x".repeat(len), CharStyle::default()); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + d.insert(Some(layer), usize::MAX, n).unwrap() + }; + let one = place(&mut d, 1); + let o = encode_read_only(&d, d.node(one).unwrap()).unwrap(); + // Everything but the text itself, with the commas between runs and paragraphs and without + // their brackets. + let overhead: usize = ["runs", "paragraphs"].iter().map(|k| o["text"][k].to_string().len() - 2).sum::<usize>() - 1; + let full = overhead + MAX_INPUT_TEXT; + let count = MAX_INPUT_TEXT_TOTAL / full; + let rest = MAX_INPUT_TEXT_TOTAL - count * full - overhead; + assert!((1..=MAX_INPUT_TEXT).contains(&rest)); + for (filler, truncated) in [(rest, [false, true]), (rest + 1, [true, false])] { + let mut ids: Vec<NodeId> = (0..count).map(|_| place(&mut d, MAX_INPUT_TEXT)).collect(); + ids.push(place(&mut d, filler)); + ids.push(one); + let (bytes, _) = encode_input_with(&d, &ids, &[InputKind::Type], usize::MAX).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + let objects = v["objects"].as_array().unwrap(); + assert_eq!(objects.len(), count + 2); + assert!(objects[..count].iter().all(|o| o["truncated"] == json!(false)), "filler {filler}"); + assert_eq!([&objects[count]["truncated"], &objects[count + 1]["truncated"]], truncated.map(|t| json!(t)).each_ref(), "filler {filler}"); + } +} + +/// A CMYK TIFF `w` × `h` pixels of no ink. +fn cmyk_blob(w: u32, h: u32) -> ImageBlob { + let inks = vectorcraft_doc::cmyk::Inks::new(w, h, vec![0; (w * h * 4) as usize]).unwrap(); + ImageBlob::cmyk_tiff(&inks).unwrap() +} + +/// `blob`, a little-endian TIFF, with its strip offset past the end of the file: its header still +/// reads and its pixels don't. +fn without_strips(blob: &ImageBlob) -> ImageBlob { + let mut bytes = blob.bytes.to_vec(); + assert_eq!(&bytes[..2], b"II"); + let word = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize; + let ifd = word(&bytes, 4); + let count = usize::from(u16::from_le_bytes([bytes[ifd], bytes[ifd + 1]])); + let entry = (0..count).map(|i| ifd + 2 + i * 12).find(|&e| bytes[e..e + 2] == 273u16.to_le_bytes()).unwrap(); + let past = (bytes.len() as u32 + 4096).to_le_bytes(); + bytes[entry + 8..entry + 12].copy_from_slice(&past); + ImageBlob::png(bytes) +} + +/// An image that can't be given isn't decoded: past the per-image limit, past what is left of the +/// total, once the total is used up, when its header disagrees with its object, and when it is +/// linked, a CMYK image as much as any other. One that fits is decoded once. +#[test] +fn an_image_that_cannot_be_given_is_not_decoded() { + let (mut d, _, layer, [_, embedded, linked, ..]) = mixed(); + d.images.insert("big".into(), ImageBlob::png(png(1, 196_609))); + d.images.insert("k".into(), cmyk_blob(2, 1)); + let big = placed(&mut d, layer, "big", (1, 196_609)); + let wrong = placed(&mut d, layer, "e", (3, 1)); + let cmyk = placed(&mut d, layer, "k", (2, 1)); + let cmyk_wrong = placed(&mut d, layer, "k", (3, 1)); + let within = |pixels: usize| Allowance { pixels, ..Allowance::FULL }; + for (id, left, omitted, decodes, what) in [ + (big, Allowance::FULL, true, 0, "past the per-image limit"), + (embedded, within(11), true, 0, "past what is left of the total"), + (embedded, within(0), true, 0, "the total used up"), + (wrong, Allowance::FULL, true, 0, "another size than its object"), + (linked, Allowance::FULL, true, 0, "linked"), + (embedded, within(12), false, 1, "fits"), + (cmyk, within(11), true, 0, "CMYK past what is left of the total"), + (cmyk_wrong, Allowance::FULL, true, 0, "CMYK of another size than its object"), + (cmyk, within(12), false, 1, "CMYK that fits"), + ] { + DECODES.with(|n| n.set(0)); + let (bytes, _) = encode_input_within(&d, &[id], &[InputKind::Image], left).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["objects"][0]["pixelsOmitted"], json!(omitted), "{what}"); + assert_eq!(DECODES.with(|n| n.get()), decodes, "{what}"); + } +} + +/// An image that reaches the decoder and fails takes its size from the total, a broken CMYK image +/// as much as a broken PNG: the next image doesn't fit what the broken one left. +#[test] +fn an_image_that_fails_to_decode_takes_from_the_total() { + let (mut d, _, layer, [_, embedded, ..]) = mixed(); + let mut png_data = png(2, 1); + let idat = png_data.windows(4).position(|w| w == b"IDAT").unwrap(); + png_data[idat + 6] ^= 0xFF; + for (what, blob, expect_cmyk) in [("PNG", ImageBlob::png(png_data), false), ("CMYK", without_strips(&cmyk_blob(2, 1)), true)] { + assert_eq!(blob.is_cmyk(), expect_cmyk, "{what}"); + d.images.insert("broken".into(), blob); + let bad = placed(&mut d, layer, "broken", (2, 1)); + let omitted = |pixels: usize| { + DECODES.with(|n| n.set(0)); + let left = Allowance { pixels, ..Allowance::FULL }; + let (bytes, _) = encode_input_within(&d, &[bad, embedded], &[InputKind::Image], left).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + (v["objects"][0]["pixelsOmitted"].clone(), v["objects"][1]["pixelsOmitted"].clone(), DECODES.with(|n| n.get())) + }; + assert_eq!(omitted(12), (json!(true), json!(true), 1), "{what}: one decode fits 12 characters"); + assert_eq!(omitted(24), (json!(true), json!(false), 2), "{what}: the next image fits what is left"); + } +} + +/// Runs in one paragraph keep their own leading: each run comes with its character attributes, and +/// the paragraph is one paragraph style. +#[test] +fn runs_in_one_paragraph_keep_their_own_leading() { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let (mut d, _, layer, _) = mixed(); + let mut t = TextObject::point(Point::new(0.0, 0.0), "", CharStyle::default()); + let leading = CharStyle { leading: Some(14.0), ..CharStyle::default() }; + t.runs = vec![TextRun::new("a".to_string(), leading), TextRun::new("b".to_string(), CharStyle::default())]; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let id = d.insert(Some(layer), usize::MAX, n).unwrap(); + let o = encode_read_only(&d, d.node(id).unwrap()).unwrap(); + assert_eq!(o["text"]["runs"][0]["style"]["leading"], json!(14.0)); + assert_eq!((&o["text"]["runs"][1]["text"], &o["text"]["runs"][1]["style"]["leading"]), (&json!("b"), &Value::Null)); + assert_eq!(o["text"]["paragraphs"].as_array().unwrap().len(), 1); +} + +/// Each read-only object's place in the input holds that object, pixels included, and the input's +/// paths have no place. An output that echoes an object is compared with what that place holds; an +/// input that doesn't hold it refuses the output, as the host's own error rather than the +/// plug-in's. +#[test] +fn read_only_objects_are_read_from_their_place_in_the_input_and_fail_closed() { + let (d, ids, _, [text, embedded, ..]) = mixed(); + let order = [ids[0], text, embedded]; + let (bytes, read_only) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(read_only.len(), 2); + let at = |id: NodeId| serde_json::from_slice::<Value>(&bytes[read_only[&id].clone()]).unwrap(); + assert_eq!(at(text), v["objects"][1]); + assert_eq!(at(embedded), v["objects"][2]); + assert!(at(embedded)["pixelsBase64"].is_string()); + let echo = json!({"objects": [v["objects"][1], v["objects"][2]]}).to_string(); + assert!(decode_output_with(echo.as_bytes(), &bytes, &read_only).is_ok()); + let garbage = vec![b'x'; bytes.len()]; + for input in [garbage.as_slice(), b"not json", br#"{"objects":[]}"#, b"{}", b""] { + let e = decode_output_with(echo.as_bytes(), input, &read_only).unwrap_err().to_string(); + assert!(!e.starts_with("plug-in"), "{e}"); + } + // `{"id": …}` alone needs nothing from the input. + let kept = json!({"objects": [{"id": text.0}, {"id": embedded.0}]}).to_string(); + assert!(decode_output_with(kept.as_bytes(), b"", &read_only).is_ok()); +} + +/// A path's effect parameters can nest deeper than the JSON reader goes. Checking the output reads +/// only the read-only objects from the input, so such a path beside one doesn't fail the run. +#[test] +fn a_path_nested_past_the_json_reader_beside_a_read_only_object_passes_the_check() { + let (mut d, ids, _, [text, ..]) = mixed(); + let params = (0..200).fold(json!(1), |inner, _| json!([inner])); + d.node_mut(ids[0]).unwrap().appearance.effects.push(vectorcraft_doc::Effect { id: "x".into(), params, visible: true }); + let (bytes, read_only) = encode_input_with(&d, &[ids[0], text], &[InputKind::Type], usize::MAX).unwrap(); + assert!(serde_json::from_slice::<Value>(&bytes).is_err(), "the input is deeper than the reader goes"); + let echo = serde_json::from_slice::<Value>(&bytes[read_only[&text].clone()]).unwrap(); + let out = decode_output_with(json!({"objects": [echo]}).to_string().as_bytes(), &bytes, &read_only).unwrap(); + assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 1); +} + /// A symbol instance comes with the symbol it shows and its transform, never its art; one whose /// symbol the document lacks says so. #[test] @@ -521,7 +727,7 @@ fn input_totals_cover_images_and_type_in_order() { assert!(objects[1]["text"]["runs"].as_array().unwrap().is_empty() && objects[4].get("pixelsBase64").is_none()); assert_eq!(objects[3]["pixelsBase64"], encode_read_only(&d, d.node(embedded).unwrap()).unwrap()["pixelsBase64"]); for o in objects { - assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); + assert!(read_only.contains_key(&NodeId(o["id"].as_u64().unwrap()))); } // Within the full totals every one of them is given. let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap(); @@ -689,7 +895,7 @@ fn a_changed_field_is_named_escaped_and_short() { let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); let key = format!("\u{202E}{}", "k".repeat(500)); v["objects"][at][key.as_str()] = json!(1); - let e = decode_output_with(v.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + let e = decode_output_with(v.to_string().as_bytes(), &bytes, &read_only).unwrap_err().to_string(); let shown = e.split('\'').nth(1).unwrap_or_default(); assert!(!e.contains('\u{202E}') && shown.starts_with("\\u{202E}kkk") && shown.ends_with('…'), "{e}"); assert_eq!(shown.chars().count(), crate::permissions::MAX_SHOWN_FIELD, "{e}"); @@ -707,7 +913,7 @@ fn read_only_kind_field_and_omission_refuse() { assert_eq!(read_only.len(), 5); let input: Value = serde_json::from_slice(&bytes).unwrap(); let before = d.clone(); - let out = decode_output_with(&bytes, &read_only).unwrap(); + let out = decode_output_with(&bytes, &bytes, &read_only).unwrap(); assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); apply_output(&mut d, &targets, out.objects, None, &keep).unwrap(); assert_eq!(d, before, "an exact echo changes nothing"); @@ -716,19 +922,20 @@ fn read_only_kind_field_and_omission_refuse() { // Numbers written another way are the same numbers. let mut echo = input.clone(); echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]); - assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok()); + assert!(decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).is_ok()); // So is an id written as a float; a fractional one names no object. let id = input["objects"][3]["id"].as_u64().unwrap(); let mut echo = input.clone(); echo["objects"][3]["id"] = json!(id as f64); - let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap(); + let out = decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).unwrap(); assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5); echo["objects"][3]["id"] = json!(id as f64 + 0.5); - assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err()); + assert!(decode_output_with(echo.to_string().as_bytes(), &bytes, &read_only).is_err()); // `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type. let ro = |id: NodeId| json!({"id": id.0}); let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]}); - let made = apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &read_only).unwrap().objects, None, &keep).unwrap(); + let made = + apply_output(&mut d, &targets, decode_output_with(out.to_string().as_bytes(), &bytes, &read_only).unwrap().objects, None, &keep).unwrap(); assert_eq!(names(&d, layer), ["?", "?", "T", "new", "E", "L", "S", "G"]); assert_eq!(made.len(), 6); assert!(ids.iter().all(|id| d.node(*id).is_none())); @@ -767,7 +974,7 @@ fn read_only_kind_field_and_omission_refuse() { "`type` must be \"path\" or \"compound\"".to_string(), ), ] { - let e = decode_output_with(out.to_string().as_bytes(), &read_only).unwrap_err().to_string(); + let e = decode_output_with(out.to_string().as_bytes(), &bytes, &read_only).unwrap_err().to_string(); assert!(e.contains(&says), "{says}: {e}"); } // Writing one or leaving one out is refused when applying too, whatever decoded the output. diff --git a/docs/mcp.md b/docs/mcp.md index 40a1e237d..07e3b7d6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6644,11 +6644,12 @@ them again at startup (`plugin.remove` deletes them there): schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` (`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so -`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of -pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts -toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that -don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error -naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the +`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, in paint order from the +bottom, with at most 16 MiB of pixels and 16 MiB of type runs over one run (an object that doesn't fit what the objects +before it left comes with `pixelsOmitted` or `truncated`; an image that reaches the decoder counts toward the pixels +even when decoding fails). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds, +transforms and names included: pixels and runs that don't fit come the same way, and an object whose other fields don't +fit makes `plugin.run` return a memory-budget error naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the memory-budget error `the selection is too large to hand over`, and more than 100,000 of these objects make it return an object-budget error. Its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the diff --git a/docs/plugins.md b/docs/plugins.md index af00957de..3369bcb83 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -522,11 +522,12 @@ In this example the character and paragraph styles are cut short; a filter gets - An image (`"image"`): `pixelSize` is its width and height in pixels; `colorMode` is `"rgb"`, `"cmyk"`, `"gray"` or `"unknown"`, read from the image itself. An embedded image whose pixels take at most 1 MiB in base64 (443 × 443 pixels fit) has them in `pixelsBase64`: straight RGBA, 8 bits a channel, row by row from the top, and - `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile. The pixels of all the - images in one input take at most 16 MiB in base64. A larger image, one whose pixels don't fit what the images before - it in paint order left, and a linked image (`linked: true`) have - `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. The host never - reads a linked image's file for a filter and never gives its path. + `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of + all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size + from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before it in + paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks an + image's size before it decodes the image. The host never reads a linked image's file for a filter and never gives + its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 97107f9e8..26a4eea84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1571,7 +1571,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. From 2336e52e2e4c522d3ade7f9d9f17c0f4d6e8924d Mon Sep 17 00:00:00 2001 From: vecteza <vecteza@gmail.com> Date: Mon, 9 Nov 2026 10:14:48 +0500 Subject: [PATCH 10/11] Workspace: window.panel.dock rejects a Tools side that isn't text side: 5 (or true, or a list) docked Tools on the side it docked at last; it is now an error like any other side but left/right/Start/End, and leaves the panel where it is. null still means the last side. --- crates/ui-egui/src/panel_docking/tests.rs | 7 +++++++ crates/ui-egui/src/tools_float.rs | 12 +++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 5eba37c31..27b1df872 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -321,6 +321,13 @@ fn the_tools_panel_docks_on_the_right_left_of_the_panels() { h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap(); assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["side"], "right", "back on the side it docked at last"); assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": "top"})).is_err()); + // A side that isn't text is an error too, not the last side: it leaves Tools where it is. + h.app.run("window.panel.float", json!({"panel": "tools"})).unwrap(); + for side in [json!(5), json!(true), json!(["left"])] { + assert!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": side})).is_err(), "{side}"); + assert!(h.app.ui.toolbar_pos.is_some(), "still floating after side {side}"); + } + assert_eq!(h.app.run("window.panel.dock", json!({"panel": "tools", "side": null})).unwrap()["side"], "right", "null: the last side"); } /// The docked Tools panel as last drawn. diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 2781e4232..0cc643342 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -219,12 +219,14 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R return None; } // Docking: on the left, or on the right left of the panels (default: the side it docked at last). - let right = match p.get("side").and_then(Value::as_str) { - None => Ok(app.ui.toolbar_right), + let right = match p.get("side") { + None | Some(Value::Null) => Ok(app.ui.toolbar_right), // `Start` and `End`, as panels take them, too. - Some(s) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => Ok(false), - Some(s) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => Ok(true), - Some(s) => Err(format!("side is left or right, not `{s}`")), + Some(Value::String(s)) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => Ok(false), + Some(Value::String(s)) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => Ok(true), + Some(Value::String(s)) => Err(format!("side is left or right, not `{s}`")), + // A number, a list…: an error, not the last side. + Some(v) => Err(format!("side is left or right, not `{v}`")), }; Some(position(p).and_then(|at| { let right = right?; From d7f017e8898f8a81a671a3d7bbd5099f6c67585c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 8 Nov 2026 22:38:51 -0800 Subject: [PATCH 11/11] Plug-ins: docs limit the 64 MiB decoder note to non-CMYK images The image decoder limit in rgba_pixels does not apply to CMYK images, which cmyk_rgba reads. Keep the separate case of a TIFF the host rejects as too large. --- docs/plugins.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index c5423e511..222626d4b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -529,9 +529,9 @@ In this example the character and paragraph styles are cut short; a filter gets it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when - its data cannot be decoded (or needs more than 64 MiB of decoder memory, or is a TIFF the host rejects as too - large), or when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it - reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never + its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and + needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. + A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never reads a linked image's file for a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`.