From 92187130711d94c7ca55ccbc20a30e98d6072dc5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:40:28 +0100 Subject: [PATCH 01/14] Step a blank Stroke Weight from 0.25 pt (#5349) --- crates/ui-egui/src/panels/tests_strokeux.rs | 54 +++++++++++++++++++++ crates/ui-egui/src/widgets/numeric.rs | 49 ++++++++++++++++--- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 4ab96f35a..0be81114c 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -243,3 +243,57 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { } assert_eq!(app.session.shown_stroke().unwrap().width, 1.0); } + +/// With objects of different weights selected the Weight field is blank, and its spinner steps +/// from 0.25 pt as Illustrator does: up gives 0.5 pt and down gives 0 pt, for all of them. +#[test] +fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (up, after) in [(true, 0.5), (false, 0.0)] { + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + let pos = egui::pos2(r.right() + sp + 8.0, r.center().y + if up { -5.0 } else { 5.0 }); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show); + } + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}"); + } + } +} + +/// ↑/↓ in the focused blank Weight field step from 0.25 pt too, for every selected object. +#[test] +fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (key, after) in [(egui::Key::ArrowUp, 0.5), (egui::Key::ArrowDown, 0.0)] { + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + // The blank field, right of the 16 px spinner. + click(&ctx, &mut app, egui::pos2(r.right() + 2.0 * sp + 16.0 + 30.0, r.center().y), stroke::show); + let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![press], stroke::show); + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, {key:?}"); + } + } +} diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 91cd18eeb..eac0f5502 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -100,6 +100,26 @@ fn unit_field( bounds: RangeInclusive, stops: &[f64], live: bool, +) -> Option { + unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None) +} + +/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with +/// ↑/↓ and the wheel instead of doing nothing. +#[allow(clippy::too_many_arguments)] +fn unit_field_from( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + share_of_value: bool, + bounds: RangeInclusive, + stops: &[f64], + live: bool, + blank_from: Option, ) -> Option { let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); @@ -111,8 +131,13 @@ fn unit_field( let read = |b: &str| typed.parse(&decimal_point(b)); let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); let stops: Vec = stops.iter().map(|&s| unit.from_pt(s)).collect(); - let stepped = - step_value(ui, &resp, &mut buf, &bounds_unit, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops); + let blank_from = blank_from.filter(|_| value.is_none()); + let read_unit = |b: &str| match read(b) { + Some(v) => Some(unit.from_pt(v)), + None if b.trim().is_empty() => blank_from.map(|v| unit.from_pt(v)), + None => None, + }; + let stepped = step_value(ui, &resp, &mut buf, &bounds_unit, (read_unit, |v| unit.format(unit.to_pt(v))), None, &stops); let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit); let commit = commit && buf != shown; if commit { @@ -566,12 +591,18 @@ pub fn spin_field_stops_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false) + let blank_from = BLANK_WEIGHT_STEPS_FROM; + let fmt = |v: f64| unit.format(v); + spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, presets, &fmt, &mut |ui, fw, label| { + unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, Some(blank_from)) }) .and_then(SpinPick::value) } +/// Where a blank Stroke Weight (the selected objects differ) steps from, in points: up goes to +/// 0.5 pt and down to 0 pt, as in Illustrator. +const BLANK_WEIGHT_STEPS_FROM: f64 = 0.25; + /// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. #[derive(Clone, Copy, Debug, PartialEq)] pub enum SpinPick { @@ -623,7 +654,7 @@ pub fn spin_field_auto_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, None, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false) }) } @@ -662,7 +693,7 @@ pub fn spin_plain_named( let label = take_previous_label(ui); let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false, false) }) .and_then(SpinPick::value) @@ -686,6 +717,7 @@ pub fn spin_kerning( ui, label, value, + None, automatic, optical, Some(optical_label), @@ -706,6 +738,7 @@ fn spin_generic( ui: &mut Ui, label: Option, value: Option, + blank_from: Option, auto: Option, optical: Option, optical_label: Option<&str>, @@ -744,12 +777,12 @@ fn spin_generic( ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. A click steps once; an + // A blank field (values that differ) steps from `blank_from`, if any. A click steps once; an // arrow held down steps again and again after a pause, as a spin button does (#4267). let repeat = held_repeat(ui, &sresp); if (repeat == Some(true) || (sresp.clicked() && repeat.is_none())) && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value + && let Some(v) = value.or(blank_from) { let modifiers = ui.input(|i| i.modifiers); out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); From 46acf1d36cc8a3bc342ab9a10fcc7c24c49187c4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:46:10 +0100 Subject: [PATCH 02/14] Crop Image: cut plain raster images from their pixels instead of re-rendering (#5534) --- crates/engine/src/cmd/menucmds.rs | 87 ++++++++++++++++++++++------- crates/engine/src/tests/menucmds.rs | 25 +++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 73cf96c9b..a2bd3b93c 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -830,6 +830,34 @@ fn polygon_path(pts: &[Point]) -> PathData { PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true)) } +/// The part of an image under `r` (in document space) copied out of its pixels, as PNG bytes, with its size and +/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read), so +/// the caller draws it instead. +fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Option<(Vec, u32, u32, Affine)> { + let blob = doc.images.get(&im.key)?; + let [a, b, c, d, _, _] = im.xf.as_coeffs(); + if blob.is_cmyk() || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 { + return None; + } + let px = decode_rgba("object.cropImage", blob).ok()?; + let inv = im.xf.inverse(); + let (p0, p1) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); + let (w, h) = px.dimensions(); + if w == 0 || h == 0 || (w, h) != (im.width, im.height) { + return None; + } + let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32; + let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w)); + let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h)); + let (x1, y1) = (x1.max(x0 + 1).min(w), y1.max(y0 + 1).min(h)); + let x0 = x0.min(x1 - 1); + let y0 = y0.min(y1 - 1); + let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image(); + let mut png = Vec::new(); + cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some((png, x1 - x0, y1 - y0, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))) +} + fn crop_image(s: &mut Session, p: &Value) -> Result { const C: &str = "object.cropImage"; let st = s.doc()?; @@ -870,6 +898,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 { return Err(bad(C, "nothing to crop")); } + // A plain, axis-aligned raster is cut straight from its pixels, so nothing is resampled. + if let Some((png, cw, ch, xf)) = crop_pixels(&st.doc, im, r) { + put_pixels(s, id, png, cw, ch, xf)?; + return Ok(json!({ "id": id.0, "width": cw, "height": ch })); + } // Keep the image's pixel density. let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9)); let mut bare = node.clone(); @@ -904,6 +937,27 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The image's pixels as RGBA, held to the allowance opening a raster has. +fn decode_rgba(c: &str, blob: &vectorcraft_doc::ImageBlob) -> Result { + // Print rasters run to gigabytes decoded: the same allowance opening them has. + let reader = || { + image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(c, "the image's pixels can't be read")) + }; + // The image's size as RGBA, read from its header, is held to that allowance before anything + // is decoded (#1717). + let (w, h) = reader()?.into_dimensions().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?; + if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { + return Err(bad(c, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = reader()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); + reader.limits(limits); + Ok(reader.decode().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?.to_rgba8()) +} + /// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the /// pixels copied as they are (no resampling) and placed where they were. fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { @@ -914,23 +968,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { if blob.is_cmyk() { return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); } - // Print rasters run to gigabytes decoded: the same allowance opening them has. - let reader = || { - image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) - .with_guessed_format() - .map_err(|_| bad(C, "the image's pixels can't be read")) - }; - // The image's size as RGBA, read from its header, is held to that allowance before anything - // is decoded (#1717). - let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; - if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { - return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); - } - let mut reader = reader()?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); - reader.limits(limits); - let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8(); + let px = decode_rgba(C, blob)?; let (w, h) = px.dimensions(); let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); for (x, y, p) in px.enumerate_pixels() { @@ -949,15 +987,22 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { let mut png = Vec::new(); cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?; let (cw, ch) = (x1 - x0, y1 - y0); + put_pixels(s, id, png, cw, ch, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))?; + Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true })) +} + +/// Crop Image's edit for a cut made from the image's own pixels: `png` (`w` × `h` px) takes the +/// image's place, mapped into the document by `xf`. +fn put_pixels(s: &mut Session, id: NodeId, png: Vec, w: u32, h: u32, xf: Affine) -> Result<()> { s.edit("Crop Image", |d, _| { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { n.kind = NodeKind::Image(Box::new(ImageObject { key, - width: cw, - height: ch, - xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), + width: w, + height: h, + xf, link: None, placement: Default::default(), pixels: None, @@ -965,7 +1010,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { } Ok(()) })?; - Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true })) + Ok(()) } /// The Control bar's Mask for an image: a clip group of the image and a clipping path on its diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index fe0783a13..1c23bcb62 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1410,6 +1410,31 @@ fn crop_image_to_rect() { assert!(s.execute("object.cropImage", &json!({})).is_err()); } +#[test] +fn crop_image_to_rect_copies_the_pixels_without_resampling() { + // 100 × 80 px of a busy pattern, placed at 2 pt a pixel. + let mut px = image::RgbaImage::new(100, 80); + for (x, y, p) in px.enumerate_pixels_mut() { + *p = image::Rgba([(x * 37 % 256) as u8, (y * 91 % 256) as u8, ((x ^ y) * 13 % 256) as u8, 255]); + } + let mut png = Vec::new(); + px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let img = selected(&s)[0]; + s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap(); + // Pixels (10.5, 5.5) to (40.5, 25.5), as points: off the pixel grid, so drawing the cut would blend + // neighbouring pixels. The cut keeps whole pixels, (11, 6) to (41, 26), as they are. + let r = s.execute("object.cropImage", &json!({"rect": [21, 11, 60, 40]})).unwrap(); + assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 20), "{r}"); + let b = bounds(&s, img); + assert!(close(b.x0, 22.0) && close(b.y0, 12.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); + let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") }; + let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(out, image::imageops::crop_imm(&px, 11, 6, 30, 20).to_image()); +} + /// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a /// null `rect` crops to the artboard as a missing one does. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2b2767ea..47f6be306 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -989,7 +989,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed is cut from its own pixels, copied as they are without resampling, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). From 6162c853b9acbab56d1c4913e678475e59b5b7f5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:42:44 +0100 Subject: [PATCH 03/14] Crop Image: cut from the pixels only when the rect is on the pixel grid, and format put_pixels (#5534) --- crates/engine/src/cmd/menucmds.rs | 25 +++++++++++-------------- crates/engine/src/tests/menucmds.rs | 29 +++++++++++++++++++++-------- docs/target-app-parity.md | 2 +- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index b0d41d9ee..4bb7de7a4 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -908,8 +908,8 @@ fn polygon_path(pts: &[Point]) -> PathData { } /// The part of an image under `r` (in document space) copied out of its pixels, as PNG bytes, with its size and -/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read), so -/// the caller draws it instead. +/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read, or +/// `r` not on the pixel grid), so the caller draws it instead. fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Option<(Vec, u32, u32, Affine)> { let blob = doc.images.get(&im.key)?; let [a, b, c, d, _, _] = im.xf.as_coeffs(); @@ -923,12 +923,17 @@ fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Op if w == 0 || h == 0 || (w, h) != (im.width, im.height) { return None; } + // Only a rect on the pixel grid is cut from the pixels; one between pixels keeps its exact bounds by being drawn. + let on_grid = |v: f64| (v - v.round()).abs() < 1e-3; + if ![p0.x, p0.y, p1.x, p1.y].into_iter().all(on_grid) { + return None; + } let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32; let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w)); let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h)); - let (x1, y1) = (x1.max(x0 + 1).min(w), y1.max(y0 + 1).min(h)); - let x0 = x0.min(x1 - 1); - let y0 = y0.min(y1 - 1); + if x1 <= x0 || y1 <= y0 { + return None; + } let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image(); let mut png = Vec::new(); cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; @@ -1075,15 +1080,7 @@ fn put_pixels(s: &mut Session, id: NodeId, png: Vec, w: u32, h: u32, xf: Aff let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); if let Some(n) = d.node_mut(id) { - n.kind = NodeKind::Image(Box::new(ImageObject { - key, - width: w, - height: h, - xf, - link: None, - placement: Default::default(), - pixels: None, - })); + n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })); } Ok(()) })?; diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index ab3bffcc0..1e4f28a87 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1513,9 +1513,8 @@ fn crop_image_to_rect() { assert!(s.execute("object.cropImage", &json!({})).is_err()); } -#[test] -fn crop_image_to_rect_copies_the_pixels_without_resampling() { - // 100 × 80 px of a busy pattern, placed at 2 pt a pixel. +/// 100 × 80 px of a busy pattern, opened as an image and scaled to 2 pt a pixel; returns the session, the image and its pixels. +fn busy_image_at_two_points_a_pixel() -> (Session, NodeId, image::RgbaImage) { let mut px = image::RgbaImage::new(100, 80); for (x, y, p) in px.enumerate_pixels_mut() { *p = image::Rgba([(x * 37 % 256) as u8, (y * 91 % 256) as u8, ((x ^ y) * 13 % 256) as u8, 255]); @@ -1527,15 +1526,29 @@ fn crop_image_to_rect_copies_the_pixels_without_resampling() { s.execute("select.all", &json!({})).unwrap(); let img = selected(&s)[0]; s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap(); - // Pixels (10.5, 5.5) to (40.5, 25.5), as points: off the pixel grid, so drawing the cut would blend - // neighbouring pixels. The cut keeps whole pixels, (11, 6) to (41, 26), as they are. - let r = s.execute("object.cropImage", &json!({"rect": [21, 11, 60, 40]})).unwrap(); + (s, img, px) +} + +#[test] +fn crop_image_to_rect_copies_the_pixels_without_resampling() { + let (mut s, img, px) = busy_image_at_two_points_a_pixel(); + // Pixels (10, 5) to (40, 25), as points: on the pixel grid, so the cut copies them as they are. + let r = s.execute("object.cropImage", &json!({"rect": [20, 10, 60, 40]})).unwrap(); assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 20), "{r}"); let b = bounds(&s, img); - assert!(close(b.x0, 22.0) && close(b.y0, 12.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); + assert!(close(b.x0, 20.0) && close(b.y0, 10.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") }; let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); - assert_eq!(out, image::imageops::crop_imm(&px, 11, 6, 30, 20).to_image()); + assert_eq!(out, image::imageops::crop_imm(&px, 10, 5, 30, 20).to_image()); +} + +#[test] +fn crop_image_to_a_rect_between_pixels_keeps_the_rect() { + let (mut s, img, _) = busy_image_at_two_points_a_pixel(); + // Pixels (10.5, 5.5) to (40.5, 25.5): off the pixel grid, so the cut is drawn and lands on the rect, not on whole pixels. + s.execute("object.cropImage", &json!({"rect": [21, 11, 60, 40]})).unwrap(); + let b = bounds(&s, img); + assert!(close(b.x0, 21.0) && close(b.y0, 11.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}"); } /// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3364b916c..e45b0dd5f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1060,7 +1060,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed is cut from its own pixels, copied as they are without resampling, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). From 0eb615b24ca53139a6730e951d21f13ed9b5da28 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:44:25 +0100 Subject: [PATCH 04/14] Step a blank Stroke Weight in points in every unit, and fix its arrow-key test (#5349) --- crates/ui-egui/src/panels/tests_strokeux.rs | 18 +++++++++++------- crates/ui-egui/src/widgets/numeric.rs | 10 ++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 0be81114c..cbc9c2727 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -245,15 +245,17 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { } /// With objects of different weights selected the Weight field is blank, and its spinner steps -/// from 0.25 pt as Illustrator does: up gives 0.5 pt and down gives 0 pt, for all of them. +/// from 0.25 pt (#5349): up gives 0.5 pt and down gives 0 pt, for all of them, whatever the +/// Units > Stroke. #[test] fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { use super::tests_appearance::frame_raw; - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - for (up, after) in [(true, 0.5), (false, 0.0)] { + for (unit, up, after) in [("points", true, 0.5), ("points", false, 0.0), ("millimeters", true, 0.5), ("millimeters", false, 0.0)] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); let mut app = app_with_rect(); + app.session.prefs.units_stroke = unit.into(); let a = app.session.active().unwrap().selection.objects[0]; let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); @@ -268,7 +270,7 @@ fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { } for id in [a.0, b] { run(&mut app, "select.set", json!({ "ids": [id] })); - assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}"); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}, {unit}"); } } } @@ -276,9 +278,11 @@ fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { /// ↑/↓ in the focused blank Weight field step from 0.25 pt too, for every selected object. #[test] fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); for (key, after) in [(egui::Key::ArrowUp, 0.5), (egui::Key::ArrowDown, 0.0)] { + // A new context each time: the field keeps its text in the context, and the 0.5 pt left by ArrowUp + // would make the blank field of the next run start from 0.5 pt. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); let mut app = app_with_rect(); let a = app.session.active().unwrap().selection.objects[0]; let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 38398cfa7..04dcaf72e 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -644,9 +644,11 @@ pub fn spin_field_stops_named( ) -> Option { let label = take_previous_label(ui); let blank_from = BLANK_WEIGHT_STEPS_FROM; + // A blank field steps in points from `blank_from`, whatever the unit's preset ladder (#5349). + let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets }; let fmt = |v: f64| unit.format(v); - spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, presets, &fmt, &mut |ui, fw, label| { - unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, Some(blank_from), "") + spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| { + unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "") }) .and_then(SpinPick::value) } @@ -655,6 +657,10 @@ pub fn spin_field_stops_named( /// 0.5 pt and down to 0 pt. This is the behaviour #5349 asks for. const BLANK_WEIGHT_STEPS_FROM: f64 = 0.25; +/// The stops a blank Stroke Weight steps through, in points, so that up gives 0.5 pt in every +/// Units > Stroke, not the first preset of the unit's own ladder above 0.25 pt. +const BLANK_WEIGHT_STOPS: [f64; 2] = [BLANK_WEIGHT_STEPS_FROM, 0.5]; + /// A distance field with spinner arrows kept within `bounds` (points), as the Drop Shadow and /// Outer Glow dialogs show their offsets and blur: the spinner steps one `unit` at a time /// (Shift ×10, Ctrl/Cmd ÷10), and it, ↑/↓, the wheel, a scrub and a typed value stop at both From 0437d987fbf02e10e1f6a4e66a59564e6a875900 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 15:09:55 +0500 Subject: [PATCH 05/14] =?UTF-8?q?#4936:=20Edit=20=E2=80=BA=20Edit=20Custom?= =?UTF-8?q?=20Dictionary=E2=80=A6=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom dictionary's words in a list, with an Entry field and Add, Delete and Change, applied on Done, as the public Check and correct spelling page describes it. The menu item moves from the Spelling submenu to the Edit menu, where the page puts it. --- crates/engine/src/cmd/spelling.rs | 6 + crates/ui-egui/src/dialogs.rs | 2 + .../ui-egui/src/dialogs/custom_dictionary.rs | 308 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus/invoke.rs | 7 + crates/ui-egui/src/menus/tree.rs | 10 +- docs/gaps.md | 5 +- docs/mcp.md | 4 + docs/roadmap.md | 9 +- docs/target-app-parity.md | 17 +- docs/type-parity.md | 5 +- 37 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/custom_dictionary.rs diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 8ae2a49c2..464c25ed2 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -223,6 +223,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result { Ok(json!({ "suggestions": suggestions })) } +/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at +/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes. +pub fn custom_word(w: &str) -> Option { + craft_proof::custom_word(w) +} + /// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be. fn word_param(p: &Value, key: &str, command: &str) -> Result { let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fd0373b72..38ccc908d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,6 +26,7 @@ mod command; pub mod composite_fonts; pub mod confirm; pub mod corners; +pub mod custom_dictionary; pub mod dimension; mod document_setup; mod duotone; @@ -432,6 +433,7 @@ registry! { NewBrush: [new_brush::KIND] => new_brush::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, EditViews: [edit_views::KIND] => edit_views::SPEC, + EditCustomDictionary: [custom_dictionary::KIND] => custom_dictionary::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, LayerOptions: [layer_options::KIND] => layer_options::SPEC, LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/custom_dictionary.rs b/crates/ui-egui/src/dialogs/custom_dictionary.rs new file mode 100644 index 000000000..ef72573ea --- /dev/null +++ b/crates/ui-egui/src/dialogs/custom_dictionary.rs @@ -0,0 +1,308 @@ +//! Edit › Edit Custom Dictionary…: the custom dictionary's words in a list, with an Entry field and +//! Add, Delete and Change, as the public +//! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) +//! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it +//! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words +//! that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as +//! it was. +//! +//! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the +//! Entry field) and `selected` (the word picked, or null). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::spelling::custom_word; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Edit Custom Dictionary. +pub const KIND: &str = "editCustomDictionary"; + +/// The command its menu item runs: with no params, it opens this dialog. +pub const COMMAND: &str = "text.spelling.dictionary"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM }; + +/// Open Edit Custom Dictionary on the custom dictionary's words. +pub fn open(app: &mut VectorcraftApp) -> Result { + let words = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_else(|| json!([])); + app.ui.dialog = Some(Dialog::new(KIND, json!({ "orig": words, "words": words, "entry": "", "selected": null }))); + Ok(Value::Null) +} + +fn list(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default() +} + +/// The word picked in the list, while it is still there. +fn selected(d: &Dialog, words: &[String]) -> Option { + d.fields.get("selected").and_then(Value::as_str).filter(|w| words.iter().any(|x| x == w)).map(String::from) +} + +/// What the buttons can do now: the Entry field as a dictionary word that isn't in the list yet +/// (for Add and Change), and the word picked (for Delete and Change). +fn actions(d: &Dialog) -> (Option, Option) { + let words = list(d, "words"); + let entry = custom_word(&d.str("entry")).filter(|w| !words.contains(w)); + (entry, selected(d, &words)) +} + +fn set_words(d: &mut Dialog, mut words: Vec, selected: Option<&str>) { + words.sort(); + words.dedup(); + d.fields.insert("words".into(), json!(words)); + d.fields.insert("selected".into(), json!(selected)); +} + +/// Add: the Entry field's word joins the list, picked. +fn add(d: &mut Dialog) { + let (Some(w), _) = actions(d) else { return }; + let mut words = list(d, "words"); + words.push(w.clone()); + set_words(d, words, Some(&w)); +} + +/// Delete: the word picked leaves the list; the one after it (or before, for the last) is picked. +fn delete(d: &mut Dialog) { + let (_, Some(w)) = actions(d) else { return }; + let mut words = list(d, "words"); + let Some(i) = words.iter().position(|x| *x == w) else { return }; + words.remove(i); + let next = words.get(i).or_else(|| words.last()).cloned(); + if let Some(n) = &next { + d.fields.insert("entry".into(), json!(n)); + } + set_words(d, words, next.as_deref()); +} + +/// Change: the word picked becomes the Entry field's word. +fn change(d: &mut Dialog) { + let (Some(to), Some(w)) = actions(d) else { return }; + let mut words: Vec = list(d, "words").into_iter().filter(|x| *x != w).collect(); + words.push(to.clone()); + set_words(d, words, Some(&to)); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let words = list(d, "words"); + let current = selected(d, &words); + let (entry, picked) = actions(d); + let mut pick = None; + let (mut added, mut deleted, mut changed) = (false, false, false); + widgets::dim_label(ui, tl!("Entry:")); + form::text(ui, d, "entry", 380.0); + ui.add_space(8.0); + ui.horizontal_top(|ui| { + widgets::list_box(ui, |ui| { + ui.set_width(260.0); + let row = ui.spacing().interact_size.y; + egui::ScrollArea::vertical().id_salt("custom-dictionary").min_scrolled_height(200.0).max_height(200.0).show_rows( + ui, + row, + words.len(), + |ui, range| { + ui.set_width(ui.available_width()); + for w in words.get(range).unwrap_or_default() { + if ui.selectable_label(current.as_ref() == Some(w), w.as_str()).clicked() { + pick = Some(w.clone()); + } + } + }, + ); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + added = ui.add_enabled_ui(entry.is_some(), |ui| widgets::flat_button(ui, tl!("Add"), 90.0).clicked()).inner; + ui.add_space(6.0); + deleted = ui.add_enabled_ui(picked.is_some(), |ui| widgets::flat_button(ui, tl!("Delete"), 90.0).clicked()).inner; + ui.add_space(6.0); + changed = ui.add_enabled_ui(entry.is_some() && picked.is_some(), |ui| widgets::flat_button(ui, tl!("Change"), 90.0).clicked()).inner; + }); + }); + // Picking a word puts it in the Entry field, to edit for Change. + if let Some(w) = pick { + d.fields.insert("entry".into(), json!(w)); + d.fields.insert("selected".into(), json!(w)); + } + if added { + add(d); + } else if deleted { + delete(d); + } else if changed { + change(d); + } + false +} + +/// Done: deletes the words no longer in the list, then adds the new ones. A word that can't be +/// saved keeps the dialog open with the error, its list as edited. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (orig, words) = (list(d, "orig"), list(d, "words")); + // The dictionary as it is now: another window may have deleted a word already. + let now = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_default(); + let kept = |w: &String| now.as_array().is_some_and(|a| a.contains(&json!(w))); + for w in orig.iter().filter(|w| !words.contains(w) && kept(w)) { + app.run("text.spelling.removeWord", json!({ "word": w })).map_err(|e| e.to_string())?; + } + for w in words.iter().filter(|w| !orig.contains(w)) { + app.run("text.spelling.addWord", json!({ "word": w })).map_err(|e| e.to_string())?; + } + app.ui.dialog = None; + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + use serde_json::{Value, json}; + + use crate::VectorcraftApp; + + // The custom dictionary is the whole process's: each test here uses words no other test does. + const A: &str = "zqvdicta"; + const B: &str = "zqvdictb"; + const M: &str = "zqvdictm"; + const X: &str = "zqvdictx"; + const Y: &str = "zqvdicty"; + + fn app() -> VectorcraftApp { + VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()) + } + + fn has(app: &mut VectorcraftApp, w: &str) -> bool { + app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().contains(&json!(w)) + } + + fn forget(app: &mut VectorcraftApp, words: &[&str]) { + for w in words { + let _ = app.run("text.spelling.removeWord", json!({ "word": w })); + } + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + /// The dialog's list, of `mine` (other tests may have their words in the dictionary meanwhile). + fn words(app: &VectorcraftApp, mine: &[&str]) -> Vec { + super::list(app.ui.dialog.as_ref().unwrap(), "words").into_iter().filter(|w| mine.contains(&w.as_str())).collect() + } + + /// One frame of the dialog on an 800 × 600 window → the text painted, with its rect. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + // A window takes a sizing frame and fades in: each frame is a quarter second on. + let time = ctx.input(|i| i.time) + 0.25; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)), + _ => {} + } + } + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + } + + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) -> Vec<(String, Rect)> { + frame(app, ctx, vec![]); + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at)]); + frame(app, ctx, vec![press(true)]); + frame(app, ctx, vec![press(false)]); + frame(app, ctx, vec![]) + } + + /// Edit › Edit Custom Dictionary… (beside Spelling, as the public page has it) opens the + /// dialog on the custom dictionary's words. + #[test] + fn the_edit_menu_opens_it_on_the_custom_dictionary() { + let mut app = app(); + forget(&mut app, &[M]); + app.run("text.spelling.addWord", json!({ "word": M })).unwrap(); + let e = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Edit Custom Dictionary…").expect("in the Edit menu"); + assert_eq!((e.path.as_slice(), e.command.as_deref(), e.enabled), (&["Edit".to_string()][..], Some(super::COMMAND), true)); + crate::menus::invoke(&mut app, super::COMMAND, json!({})); + let d = app.ui.dialog.as_ref().expect("Edit Custom Dictionary opens"); + assert_eq!(d.kind, super::KIND); + assert_eq!(words(&app, &[M]), [M]); + forget(&mut app, &[M]); + } + + /// Add, Delete and Change edit the list; nothing changes in the dictionary until Done, which + /// applies them; Cancel leaves it as it was. + #[test] + fn add_delete_and_change_apply_on_done() { + let mut app = app(); + forget(&mut app, &[A, B, "zqvdictc"]); + app.run("text.spelling.addWord", json!({ "word": A })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + // Add: the Entry field's word (kept in lower case) joins the list. + set(&mut app, "entry", json!(" ZqvDictB ")); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [A, B]); + assert!(!has(&mut app, B), "not until Done"); + // Change: pick A, edit it in the Entry field. + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), [B, "zqvdictc"]); + // Delete the word picked. + set(&mut app, "selected", json!(B)); + click(&mut app, &ctx, "Delete"); + assert_eq!(words(&app, &[A, B, "zqvdictc"]), ["zqvdictc"]); + // Cancel: the dictionary is as it was. + crate::dialogs::cancel(&mut app); + assert!(has(&mut app, A) && !has(&mut app, B) && !has(&mut app, "zqvdictc")); + // Again, then Done. + super::open(&mut app).unwrap(); + set(&mut app, "selected", json!(A)); + set(&mut app, "entry", json!("zqvdictc")); + click(&mut app, &ctx, "Change"); + set(&mut app, "entry", json!(B)); + click(&mut app, &ctx, "Add"); + click(&mut app, &ctx, "Done"); + assert!(app.ui.dialog.is_none(), "Done closes it"); + assert!(!has(&mut app, A) && has(&mut app, B) && has(&mut app, "zqvdictc")); + forget(&mut app, &[A, B, "zqvdictc"]); + } + + /// Add and Change need one word that isn't in the list yet; Delete and Change need a word + /// picked. A button that can't act does nothing. + #[test] + fn the_buttons_act_only_on_a_word_they_can_take() { + let mut app = app(); + forget(&mut app, &[X, Y]); + app.run("text.spelling.addWord", json!({ "word": X })).unwrap(); + super::open(&mut app).unwrap(); + let ctx = context(); + for entry in ["", "two words", X, &"z".repeat(65)] { + set(&mut app, "entry", json!(entry)); + assert_eq!(super::actions(app.ui.dialog.as_ref().unwrap()).0, None, "{entry:?}"); + click(&mut app, &ctx, "Add"); + assert_eq!(words(&app, &[X, Y]), [X], "{entry:?} isn't added"); + } + // Nothing picked: Delete and Change do nothing. + set(&mut app, "entry", json!(Y)); + click(&mut app, &ctx, "Delete"); + click(&mut app, &ctx, "Change"); + assert_eq!(words(&app, &[X, Y]), [X]); + crate::dialogs::cancel(&mut app); + forget(&mut app, &[X]); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e1121be66..ea7cb7037 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6440,3 +6440,5 @@ breakText Glyphs Глифи # Align to Pixel Grid Align to Pixel Grid Подравняване към пикселната мрежа @msg side is left or right, not `{s}` side е left или right, а не `{s}` + Edit Custom Dictionary Редактиране на потребителски речник + Entry: Запис: diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d4749aba4..fc89a3d78 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6491,3 +6491,5 @@ shape Open Otvoren # Align to Pixel Grid Align to Pixel Grid Poravnaj s mrežom piksela @msg side is left or right, not `{s}` side je left ili right, a ne `{s}` + Edit Custom Dictionary Uredi prilagođeni rječnik + Entry: Unos: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 91169aaa4..cc1845ac9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6675,3 +6675,5 @@ breakText Glyphs Glifs Ignore All Ignora-ho tot Add to Dictionary Afegeix al diccionari No Suggestions No hi ha suggeriments + Edit Custom Dictionary Edita el diccionari personalitzat + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91d86612c..67e4e0f29 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6694,3 +6694,5 @@ breakText Glyphs Glyfy Ignore All Ignorovat vše Add to Dictionary Přidat do slovníku No Suggestions Žádné návrhy + Edit Custom Dictionary Upravit vlastní slovník + Entry: Položka: diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6348e68a4..a0541cc63 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6454,3 +6454,5 @@ breakText Glyphs Glyffer # Align to Pixel Grid Align to Pixel Grid Juster efter pixelgitter @msg side is left or right, not `{s}` side er left eller right, ikke `{s}` + Edit Custom Dictionary Redigér egen ordliste + Entry: Opslag: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ffe6eeb9..8c90460e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6281,3 +6281,5 @@ breakText Glyphs Glyphen Ignore All Alle ignorieren Add to Dictionary Zum Wörterbuch hinzufügen No Suggestions Keine Vorschläge + Edit Custom Dictionary Benutzerwörterbuch bearbeiten + Entry: Eintrag: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6dc7a40ea..be38e9531 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6646,3 +6646,5 @@ breakText Glyphs Γλύφοι Ignore All Παράβλεψη όλων Add to Dictionary Προσθήκη στο λεξικό No Suggestions Καμία πρόταση + Edit Custom Dictionary Επεξεργασία προσαρμοσμένου λεξικού + Entry: Λήμμα: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0f3947277..06b41b90d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6637,3 +6637,5 @@ breakText Glyphs Glifos Ignore All Omitir todo Add to Dictionary Añadir al diccionario No Suggestions No hay sugerencias + Edit Custom Dictionary Editar diccionario personalizado + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e2ab48874..74c1dea15 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6729,3 +6729,5 @@ breakText Glyphs Merkit Ignore All Ohita kaikki Add to Dictionary Lisää sanastoon No Suggestions Ei ehdotuksia + Edit Custom Dictionary Muokkaa omaa sanastoa + Entry: Merkintä: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c2c333b6b..275e10c7b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6689,3 +6689,5 @@ breakText Glyphs Glyphes Ignore All Tout ignorer Add to Dictionary Ajouter au dictionnaire No Suggestions Aucune suggestion + Edit Custom Dictionary Modifier le dictionnaire personnalisé + Entry: Entrée : diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d39ab857f..3fc0e8d3d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8135,3 +8135,5 @@ theme Appearance अपीयरेंस Ignore All सभी को अनदेखा करें Add to Dictionary शब्दकोश में जोड़ें No Suggestions कोई सुझाव नहीं + Edit Custom Dictionary कस्टम शब्दकोश संपादित करें + Entry: प्रविष्टि: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b70f11db..2d016c433 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6678,3 +6678,5 @@ breakText Glyphs Glifák Ignore All Az összes kihagyása Add to Dictionary Hozzáadás a szótárhoz No Suggestions Nincs javaslat + Edit Custom Dictionary Egyéni szótár szerkesztése + Entry: Bejegyzés: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ed0ce6d4e..cdaffd788 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6454,3 +6454,5 @@ breakText Glyphs Glif # Align to Pixel Grid Align to Pixel Grid Sejajarkan ke grid piksel @msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}` + Edit Custom Dictionary Edit kamus kustom + Entry: Entri: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 975800f1f..d2784a4ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6315,3 +6315,5 @@ breakText Glyphs Glifi Ignore All Ignora tutto Add to Dictionary Aggiungi al dizionario No Suggestions Nessun suggerimento + Edit Custom Dictionary Modifica dizionario personalizzato + Entry: Voce: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e0df1a47..fa863ec79 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6380,3 +6380,5 @@ breakText Glyphs 字形 Ignore All すべて無視 Add to Dictionary 辞書に追加 No Suggestions 候補なし + Edit Custom Dictionary カスタム辞書を編集 + Entry: エントリ: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0ff9696f7..287b26e70 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6755,3 +6755,5 @@ font-script Japanese იაპონური Ignore All ყველას იგნორირება Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის + Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Entry: ჩანაწერი: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b68b4b308..135100f99 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6276,3 +6276,5 @@ breakText Glyphs 글리프 Ignore All 모두 무시 Add to Dictionary 사전에 추가 No Suggestions 제안 없음 + Edit Custom Dictionary 사용자 사전 편집 + Entry: 항목: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 005972780..904c0863a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6505,3 +6505,5 @@ breakText Glyphs Glyphs # Align to Pixel Grid Align to Pixel Grid Uitlijnen op pixelraster @msg side is left or right, not `{s}` side is left of right, niet `{s}` + Edit Custom Dictionary Aangepast woordenboek bewerken + Entry: Item: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index feb3d8308..5a71e3e1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6550,3 +6550,5 @@ breakText Glyphs Glify Ignore All Ignoruj wszystko Add to Dictionary Dodaj do słownika No Suggestions Brak sugestii + Edit Custom Dictionary Edytuj słownik użytkownika + Entry: Wpis: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e0e83aaec..2fea8a249 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6767,3 +6767,5 @@ swatch Yellow Green Verde-amarelado Ignore All Ignorar Tudo Add to Dictionary Adicionar ao Dicionário No Suggestions Nenhuma Sugestão + Edit Custom Dictionary Editar Dicionário Personalizado + Entry: Entrada: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7ccd9c605..9a755766e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6783,3 +6783,5 @@ shape Open Открытый Ignore All Пропустить все Add to Dictionary Добавить в словарь No Suggestions Нет вариантов + Edit Custom Dictionary Редактировать пользовательский словарь + Entry: Запись: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a732ff6c0..5d4d51713 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8179,3 +8179,5 @@ digits Hindi Hindi Ignore All Ignorera alla Add to Dictionary Lägg till i ordlistan No Suggestions Inga förslag + Edit Custom Dictionary Redigera egen ordlista + Entry: Post: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dad8615f5..c0f2c92ef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6650,3 +6650,5 @@ breakText Glyphs Glifler Ignore All Tümünü Yoksay Add to Dictionary Sözlüğe Ekle No Suggestions Öneri Yok + Edit Custom Dictionary Özel Sözlüğü Düzenle + Entry: Giriş: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fcc681b5..7dd65d9f8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6295,3 +6295,5 @@ breakText Glyphs Гліфи Ignore All Пропустити все Add to Dictionary Додати до словника No Suggestions Немає варіантів + Edit Custom Dictionary Редагувати словник користувача + Entry: Запис: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5151b4a57..eeab12613 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6296,3 +6296,5 @@ breakText Glyphs Glyph Ignore All Bỏ qua tất cả Add to Dictionary Thêm vào từ điển No Suggestions Không có gợi ý + Edit Custom Dictionary Chỉnh sửa từ điển tùy chỉnh + Entry: Mục nhập: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e527dbb1f..3658297fa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6532,3 +6532,5 @@ breakText Glyphs 字形 Align to Pixel Grid 对齐像素网格 @msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 @msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}` + Edit Custom Dictionary 编辑自定义词典 + Entry: 条目: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 16e6898bf..c97eef30a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6330,3 +6330,5 @@ breakText Glyphs 字符 Align to Pixel Grid 對齊像素格點 @msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 @msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}` + Edit Custom Dictionary 編輯自訂字典 + Entry: 項目: diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 7b3adaffe..c37a8268c 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -287,6 +287,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } + // Edit Custom Dictionary…: the custom dictionary's words in a list, to add, delete or change. + if id == crate::dialogs::custom_dictionary::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::custom_dictionary::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index ebea83e90..97651d074 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -1,6 +1,6 @@ use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS}; use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items}; -use super::{Item, Sep, c, cp, json, sub, todo, todos}; +use super::{Item, Sep, c, cp, json, sub, todos}; /// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see /// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`). @@ -97,10 +97,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { Sep, cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})), cp("Find Next", "edit.findNext", json!({"find": ""})), - sub( - "Spelling", - vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")], - ), + sub("Spelling", vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I")]), + // Edit › Edit Custom Dictionary…, beside Spelling, on the public Check and correct + // spelling page. + c("Edit Custom Dictionary…", crate::dialogs::custom_dictionary::COMMAND), Sep, sub( "Edit Colors", diff --git a/docs/gaps.md b/docs/gaps.md index 0b910710f..1d2b4c933 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -449,7 +449,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Missing:** CJK vertical composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and - `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Check Spelling and Edit Custom Dictionary dialogs remain), + `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Edit Custom Dictionary dialog; the Check Spelling dialog remains), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). - **Impact:** professional typography and Japanese/Chinese publishing. @@ -854,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index a9b1f1cd8..b2437347d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6115,6 +6115,10 @@ preferences, one word per line; without preferences (`VECTORCRAFT_NO_PREFS`, the closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's checks skip the word, in any case, while the document is open. It is not saved and not an undo step. +In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of +the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.removeWord` and +`text.spelling.addWord` for the words that differ, and Cancel changes nothing. + ```json {"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}} {"name":"run_command","arguments":{"command":"text.spelling.ignoreAll","params":{"word":"Zqvwkx"}}} diff --git a/docs/roadmap.md b/docs/roadmap.md index 115ba760d..2fed7e653 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307). @@ -139,9 +139,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. | | 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. | | 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c76d781c8..38ccd4567 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -213,15 +213,15 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check done, #4936; the dialogs remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -482,10 +482,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | -| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands and Auto Spell Check are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs remain; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | @@ -1914,6 +1914,8 @@ The right-click followed (2026-11-10), as the same page's "Correct spelling erro The page's other way to turn Auto Spell Check on followed (2026-11-10): right-click the text and choose Spelling › Auto Spell Check (#4936). A right-click on type (which selects it) has a Spelling submenu with Auto Spell Check, which runs `edit.autoSpellCheck` as Edit › Spelling does. The context menu draws no check marks, its toggles saying what they do (Show Rulers, Hide Guides), but a submenu that holds only toggles shows them, so Auto Spell Check is checked while it is on; other art has no Spelling submenu (`right_click_on_type_turns_auto_spell_check_on_and_off`). +Edit Custom Dictionary followed (2026-11-10), as the same page's "Edit the custom dictionary" describes it: Edit › Edit Custom Dictionary…, then enter a word in the Entry field and Add it, select a word and Delete it, or select a word, edit it in the Entry field and Change it, then Done (#4936). The dialog lists the custom dictionary's words (`text.spelling.dictionary`); picking one puts it in the Entry field. Add and Change take one word that isn't listed yet, kept in lower case as the dictionary keeps it, and Delete and Change need a word picked. Nothing changes until Done, which deletes and adds the words that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as it was (`ui-egui/src/dialogs/custom_dictionary.rs`). The Check Spelling dialog remains. + ## Control bar: Paragraph section while type is selected (2026-11-06) With type selected, the Control bar shows a Paragraph: link and the Align left, Align center and Align right buttons after its Character controls, as the public documentation has the Control panel format paragraphs while the Type tool is in use, with a way to the Paragraph panel ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html)), and its alignment buttons align text as the Paragraph panel's do ([Align text](https://helpx.adobe.com/illustrator/using/align-text.html)). A button aligns the selected type (`text.setStyle {justify}`, one undo step) and the shared alignment shows selected; the link opens the Paragraph panel in a popover under it, as Character: opens the Character panel. Keeping to the three alignments that don't justify is our choice, for the bar's room; the popover has all seven. Short of room the bar leaves controls out from its right end: the inline X/Y/W/H fields, the Distribute and Align buttons, area type's vertical alignment, then these buttons and last the link, keeping room for the Transform link; on a 1440 pt bar with one point type object, the buttons show (pinned by a headless test, #5372). @@ -1930,6 +1932,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Type advanced: the Edit Custom Dictionary dialog, #4936; evidence added; the Check Spelling dialog remains; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: Spelling › Auto Spell Check in the context menu of type, checked while it is on, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Type advanced: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined, #4936; evidence added; the dialogs remain; scores and hours unchanged. | | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 8f3c8e7c2..54b009723 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | -| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. The Check Spelling and Edit Custom Dictionary dialogs remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). | +| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | | Smart Punctuation, Change Case, Find & Replace | done | | | Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. | | 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. | From 4a954a6a843152c5a8ad4b9036c3cc6e7f1ffc00 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 21:48:25 +0500 Subject: [PATCH 06/14] Edit Custom Dictionary: Done saves its deletes and adds together, all or none (part of #4936) Done ran text.spelling.removeWord and text.spelling.addWord one word at a time, each saving the file, so a save that failed after a delete was saved left that word deleted even after Cancel. text.spelling.editDictionary {remove, add} makes every change, saves once and takes all of them back when the save fails; Done runs it once. --- crates/engine/src/cmd/spelling.rs | 65 +++++++++++++++++++ crates/engine/src/tests/spelling.rs | 35 ++++++++++ .../ui-egui/src/dialogs/custom_dictionary.rs | 50 ++++++++++---- docs/mcp.md | 5 +- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 464c25ed2..641f6a4fe 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -85,6 +85,15 @@ pub fn specs() -> Vec { always, change_word ), + cmd!( + query "text.spelling.editDictionary", + "Edit Custom Dictionary", + [], + None, + "{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}", + always, + edit_dictionary + ), cmd!( query "text.spelling.ignoreAll", "Ignore All", @@ -348,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result { Ok(json!({ "word": to })) } +/// Most words `text.spelling.editDictionary` takes in each of its lists. +const MAX_EDIT_WORDS: usize = 10_000; + +/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated. +fn word_list(p: &Value, key: &str, command: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) }; + let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?; + if words.len() > MAX_EDIT_WORDS { + return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words"))); + } + words + .iter() + .map(|w| { + w.as_str().and_then(craft_proof::custom_word).ok_or_else(|| { + bad(command, format!("`{key}` must hold words of one word, at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)) + }) + }) + .collect() +} + +/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made, +/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the +/// dictionary as it was rather than half changed. +fn edit_dictionary(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.spelling.editDictionary"; + let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?); + let before: BTreeSet = craft_proof::user_dictionary().into_iter().collect(); + // A word in both lists stays; a word to delete that another window deleted already is skipped. + let removed: Vec = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect(); + let added: Vec = add.iter().filter(|w| !before.contains(*w)).cloned().collect(); + if removed.is_empty() && added.is_empty() { + return Ok(json!({ "removed": removed, "added": added })); + } + for w in &removed { + craft_proof::remove_word(w); + } + for w in &added { + craft_proof::add_word(w); + } + let saved = save_dictionary(s, &removed, C); + dictionary_changed(); + saved.inspect_err(|_| { + for w in &added { + craft_proof::remove_word(w); + } + for w in &removed { + craft_proof::add_word(w); + } + // Words the save took in from the file stay, as for one word. + if !added.is_empty() { + retake_file_words(s); + } + })?; + Ok(json!({ "removed": removed, "added": added })) +} + /// `text.spelling.ignoreAll`: Ignore All, for the active document. fn ignore_all(s: &mut Session, p: &Value) -> Result { const C: &str = "text.spelling.ignoreAll"; diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index eca38d8e6..889458ac5 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -260,6 +260,41 @@ fn a_change_that_cant_be_saved_is_taken_back() { run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"})); } +/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save +/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise). +#[test] +fn a_dictionary_edit_is_saved_whole_or_not_at_all() { + let _one_at_a_time = dictionary_lock(); + const EDIT: &str = "text.spelling.editDictionary"; + let dir = vectorcraft_testkit::temp_dir("spelling-edit"); + let file = dir.join("Custom Dictionary.txt"); + std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap(); + let mut s = session(); + s.attach_custom_dictionary(file.clone()); + // A word already gone and a word already there are skipped; added words are kept in lower case. + let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]})); + assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]})); + let saved = std::fs::read_to_string(&file).unwrap(); + let lines: Vec<&str> = saved.lines().collect(); + assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}"); + // A save that fails takes back every delete and add. + let folder = dir.join("folder"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = session(); + failing.attach_custom_dictionary(folder); + assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err()); + assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back"); + assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back"); + // A list that isn't one of words is refused before anything changes. + let many: Vec = (0..10_001).map(|i| format!("pqzv{i}")).collect(); + for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] { + assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list"); + assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing"); + } + run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]})); + assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc")); +} + #[cfg(unix)] #[test] fn a_word_another_window_saved_stays_when_this_save_fails() { diff --git a/crates/ui-egui/src/dialogs/custom_dictionary.rs b/crates/ui-egui/src/dialogs/custom_dictionary.rs index ef72573ea..8a61f7095 100644 --- a/crates/ui-egui/src/dialogs/custom_dictionary.rs +++ b/crates/ui-egui/src/dialogs/custom_dictionary.rs @@ -3,7 +3,7 @@ //! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html) //! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it //! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words -//! that differ (`text.spelling.removeWord`, `text.spelling.addWord`); Cancel leaves the dictionary as +//! that differ in one `text.spelling.editDictionary`, all or none; Cancel leaves the dictionary as //! it was. //! //! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the @@ -22,6 +22,9 @@ pub const KIND: &str = "editCustomDictionary"; /// The command its menu item runs: with no params, it opens this dialog. pub const COMMAND: &str = "text.spelling.dictionary"; +/// The command Done runs: every delete and add, saved once. +const EDIT: &str = "text.spelling.editDictionary"; + pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM }; @@ -136,19 +139,14 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// Done: deletes the words no longer in the list, then adds the new ones. A word that can't be -/// saved keeps the dialog open with the error, its list as edited. +/// Done: deletes the words no longer in the list and adds the new ones in one +/// `text.spelling.editDictionary`, saved once. When it can't be saved, none of it is kept: the +/// dialog stays open with the error, its list as edited, and Cancel leaves the dictionary as it was. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (orig, words) = (list(d, "orig"), list(d, "words")); - // The dictionary as it is now: another window may have deleted a word already. - let now = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_default(); - let kept = |w: &String| now.as_array().is_some_and(|a| a.contains(&json!(w))); - for w in orig.iter().filter(|w| !words.contains(w) && kept(w)) { - app.run("text.spelling.removeWord", json!({ "word": w })).map_err(|e| e.to_string())?; - } - for w in words.iter().filter(|w| !orig.contains(w)) { - app.run("text.spelling.addWord", json!({ "word": w })).map_err(|e| e.to_string())?; - } + let remove: Vec<&String> = orig.iter().filter(|w| !words.contains(w)).collect(); + let add: Vec<&String> = words.iter().filter(|w| !orig.contains(w)).collect(); + app.run(EDIT, json!({ "remove": remove, "add": add })).map_err(|e| e.to_string())?; app.ui.dialog = None; Ok(Value::Null) } @@ -282,6 +280,34 @@ mod tests { forget(&mut app, &[A, B, "zqvdictc"]); } + /// Done that can't be saved keeps none of the edits: the dialog stays open with the error and + /// Cancel leaves the dictionary as it was. + #[test] + fn a_done_that_cant_be_saved_changes_nothing() { + const P: &str = "zqvdictp"; + const Q: &str = "zqvdictq"; + let mut app = app(); + forget(&mut app, &[P, Q]); + app.run("text.spelling.addWord", json!({ "word": P })).unwrap(); + let folder = vectorcraft_testkit::temp_dir("custom-dictionary-done").join("Custom Dictionary.txt"); + std::fs::create_dir_all(&folder).unwrap(); + let mut failing = self::app(); + failing.session.attach_custom_dictionary(folder); + super::open(&mut failing).unwrap(); + let ctx = context(); + set(&mut failing, "selected", json!(P)); + click(&mut failing, &ctx, "Delete"); + set(&mut failing, "entry", json!(Q)); + click(&mut failing, &ctx, "Add"); + assert_eq!(words(&failing, &[P, Q]), [Q]); + click(&mut failing, &ctx, "Done"); + assert!(failing.ui.dialog.is_some(), "the dialog stays open with the error"); + assert!(has(&mut app, P) && !has(&mut app, Q), "neither the delete nor the add is kept"); + crate::dialogs::cancel(&mut failing); + assert!(has(&mut app, P) && !has(&mut app, Q)); + forget(&mut app, &[P, Q]); + } + /// Add and Change need one word that isn't in the list yet; Delete and Change need a word /// picked. A button that can't act does nothing. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 7571b017f..67ce12721 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6135,8 +6135,9 @@ closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's ch any case, while the document is open. It is not saved and not an undo step. In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of -the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.removeWord` and -`text.spelling.addWord` for the words that differ, and Cancel changes nothing. +the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.editDictionary {remove, add}` for +the words that differ, which deletes and adds them and saves once, all or none (a save that fails keeps none of them), +and Cancel changes nothing. ```json {"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}} From 5080a8eced487d7b4e6030c199349bdac516a620 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Tue, 10 Nov 2026 22:13:23 +0500 Subject: [PATCH 07/14] text.spelling.editDictionary: say each list entry must be a single word (part of #4936) --- crates/engine/src/cmd/spelling.rs | 6 +++--- crates/engine/src/tests/spelling.rs | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 641f6a4fe..35d1a1277 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -370,9 +370,9 @@ fn word_list(p: &Value, key: &str, command: &str) -> Result> { words .iter() .map(|w| { - w.as_str().and_then(craft_proof::custom_word).ok_or_else(|| { - bad(command, format!("`{key}` must hold words of one word, at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)) - }) + w.as_str() + .and_then(craft_proof::custom_word) + .ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS))) }) .collect() } diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index 889458ac5..16d7f0199 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -291,6 +291,8 @@ fn a_dictionary_edit_is_saved_whole_or_not_at_all() { assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list"); assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing"); } + let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string(); + assert!(e.contains("must hold single words of at most 64 characters"), "{e}"); run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]})); assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc")); } From eac92af53770c3154ab0e1ab0eb020edb5c4fd9c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:36:09 +0100 Subject: [PATCH 08/14] Find the Weight spinner in the blank-weight tests from its rect, not fixed offsets (#5349) --- crates/ui-egui/src/panels/tests_strokeux.rs | 28 ++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index cbc9c2727..12535e27e 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -244,6 +244,15 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { assert_eq!(app.session.shown_stroke().unwrap().width, 1.0); } +/// The screen rect of the Weight spinner in the Stroke panel, found by its accessibility label, so +/// the tests do not depend on where the panel puts the field. +fn weight_spinner_rect(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Rect { + let out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui)); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some("Step up or down")).expect("the weight spinner").1.bounds().expect("its bounds"); + egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)) +} + /// With objects of different weights selected the Weight field is blank, and its spinner steps /// from 0.25 pt (#5349): up gives 0.5 pt and down gives 0 pt, for all of them, whatever the /// Units > Stroke. @@ -253,6 +262,7 @@ fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { for (unit, up, after) in [("points", true, 0.5), ("points", false, 0.0), ("millimeters", true, 0.5), ("millimeters", false, 0.0)] { let ctx = egui::Context::default(); + ctx.enable_accesskit(); crate::theme::install_fonts(&ctx); let mut app = app_with_rect(); app.session.prefs.units_stroke = unit.into(); @@ -260,10 +270,10 @@ fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); run(&mut app, "select.set", json!({ "ids": [a.0, b] })); - let t = texts(&ctx, &mut app, stroke::show); - let r = text_rect(&t, "Weight:"); - let sp = ctx.global_style().spacing.item_spacing.x; - let pos = egui::pos2(r.right() + sp + 8.0, r.center().y + if up { -5.0 } else { 5.0 }); + texts(&ctx, &mut app, stroke::show); + let sr = weight_spinner_rect(&ctx, &mut app); + // The up arrow is the upper half of the spinner, the down arrow the lower half. + let pos = egui::pos2(sr.center().x, sr.center().y + if up { -sr.height() / 4.0 } else { sr.height() / 4.0 }); let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show); @@ -282,17 +292,17 @@ fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() { // A new context each time: the field keeps its text in the context, and the 0.5 pt left by ArrowUp // would make the blank field of the next run start from 0.5 pt. let ctx = egui::Context::default(); + ctx.enable_accesskit(); crate::theme::install_fonts(&ctx); let mut app = app_with_rect(); let a = app.session.active().unwrap().selection.objects[0]; let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); run(&mut app, "select.set", json!({ "ids": [a.0, b] })); - let t = texts(&ctx, &mut app, stroke::show); - let r = text_rect(&t, "Weight:"); - let sp = ctx.global_style().spacing.item_spacing.x; - // The blank field, right of the 16 px spinner. - click(&ctx, &mut app, egui::pos2(r.right() + 2.0 * sp + 16.0 + 30.0, r.center().y), stroke::show); + texts(&ctx, &mut app, stroke::show); + // The blank field, right of the spinner. + let sr = weight_spinner_rect(&ctx, &mut app); + click(&ctx, &mut app, egui::pos2(sr.right() + 30.0, sr.center().y), stroke::show); let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; frame_events(&ctx, &mut app, vec![press], stroke::show); for id in [a.0, b] { From 4fc392977deb5d59a28eead6b93859da46308b9d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:36:34 +0100 Subject: [PATCH 09/14] Crop Image: check the pixel grid before decoding the image (#5534) --- crates/engine/src/cmd/menucmds.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 93284fe70..0a4c93dc5 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -962,18 +962,19 @@ fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Op if blob.is_cmyk() || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 { return None; } - let px = decode_rgba("object.cropImage", blob).ok()?; let inv = im.xf.inverse(); let (p0, p1) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); - let (w, h) = px.dimensions(); - if w == 0 || h == 0 || (w, h) != (im.width, im.height) { - return None; - } // Only a rect on the pixel grid is cut from the pixels; one between pixels keeps its exact bounds by being drawn. + // Checked before decoding, so an off-grid crop of a large image doesn't decode it for nothing. let on_grid = |v: f64| (v - v.round()).abs() < 1e-3; if ![p0.x, p0.y, p1.x, p1.y].into_iter().all(on_grid) { return None; } + let px = decode_rgba("object.cropImage", blob).ok()?; + let (w, h) = px.dimensions(); + if w == 0 || h == 0 || (w, h) != (im.width, im.height) { + return None; + } let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32; let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w)); let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h)); From a7df8985f98feb459233d78d3a481aa7ce8f1612 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:38:50 +0100 Subject: [PATCH 10/14] Clear the textures delta in the Weight spinner test helper (#5349) --- crates/ui-egui/src/panels/tests_strokeux.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 12535e27e..7de3fc163 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -247,8 +247,9 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { /// The screen rect of the Weight spinner in the Stroke panel, found by its accessibility label, so /// the tests do not depend on where the panel puts the field. fn weight_spinner_rect(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Rect { - let out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui)); - let nodes = &out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui)); + out.textures_delta.clear(); + let nodes =&out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; let b = nodes.iter().find(|(_, n)| n.label() == Some("Step up or down")).expect("the weight spinner").1.bounds().expect("its bounds"); egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)) } From e9069e1418c127d919c3b5bed1778bf2bceb5c9b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:39:05 +0100 Subject: [PATCH 11/14] Restore the spacing in the Weight spinner test helper (#5349) --- crates/ui-egui/src/panels/tests_strokeux.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 7de3fc163..2c7203270 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -249,7 +249,7 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { fn weight_spinner_rect(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Rect { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui)); out.textures_delta.clear(); - let nodes =&out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; + let nodes = &out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes; let b = nodes.iter().find(|(_, n)| n.label() == Some("Step up or down")).expect("the weight spinner").1.bounds().expect("its bounds"); egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)) } From 6484e72dfff19ebd7a691b09a796c6d90e68576f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:14:22 -0800 Subject: [PATCH 12/14] 3D: Extrude & Bevel and Inflate build detailed shapes they refused as too complex Each Extrude & Bevel and Inflate solid now takes up to 65,536 outline points (was 2,048) and 163,840 faces (was 32,768), VectorCraft's own limits, as Revolve's. Before any point is sampled, the outline's lines and curves are counted with each curve's tolerance pieces, and the faces the walls, bevels and caps (Extrude) or the sides (Inflate) need. When they do not fit, every curve takes fewer pieces, down to 4; a shape that does not fit at 4 is refused with the existing surface error before any face is built. The subpath count is capped at the point limit too. The error texts are unchanged. A closed path of 2,000 curves now extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings. Docs: docs/three-d.md, docs/mcp.md, docs/target-app-parity.md. --- crates/three-d/src/lib.rs | 2 + crates/three-d/src/solid.rs | 5 +- crates/three-d/src/solid/build.rs | 32 +++-- crates/three-d/src/solid/inflate.rs | 22 +++- crates/three-d/src/solid/rings.rs | 95 ++++++++++++--- crates/three-d/src/solid/tests.rs | 41 ++++--- crates/three-d/src/solid/tests_limits.rs | 141 +++++++++++++++++++++++ docs/mcp.md | 4 + docs/target-app-parity.md | 1 + docs/three-d.md | 16 ++- 10 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 crates/three-d/src/solid/tests_limits.rs diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index fa22c10fb..5606cb884 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -25,6 +25,8 @@ const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; /// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. const MAX_REVOLVE_FACES: usize = 163_840; +/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. +const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 4d6e78188..f4e11270e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2}; -use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; +use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale}; mod build; mod inflate; @@ -319,3 +319,6 @@ mod tests; #[cfg(test)] mod tests_inflate_shading; + +#[cfg(test)] +mod tests_limits; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 9948c4796..d03773ccd 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,7 @@ -use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings}; -use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; +use super::rings::{ + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings, +}; +use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -87,8 +89,8 @@ impl Builder<'_> { } if let Some(mesh) = &mut self.mesh { mesh.push((contours, n.unit())); - if mesh.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if mesh.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } return Ok(()); } @@ -135,8 +137,8 @@ impl Builder<'_> { #[cfg(test)] view, }); - if self.facets.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if self.facets.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } Ok(()) } @@ -407,15 +409,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio if o.depth <= 0.0 { return plane_faces(path, rule, bounds, view, mesh); } - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let steps = steps(&o); + // One face for each edge of the outline in each band between two steps, and the two caps: + // counted before any point is sampled, and again on the rings, which can have more points + // once contours that meet are merged. + let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, bands, caps)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; - let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with - // bevels and no walls, as its shallowest band is), within the facet limit. + // bevels and no walls, as its shallowest band is), within [`MAX_FACES`] facets. let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); let wall = steps .windows(2) @@ -465,7 +477,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`plane`], or with `mesh` its face before projection instead of its facet. pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 51f9af911..b3de81e61 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,12 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; -use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings}; -use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; +use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; +use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; + +/// The slices the sides are cut into: more to follow a twist or taper. +fn slices(o: &Inflate) -> usize { + if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 } +} /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { @@ -18,11 +23,14 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); } - let size = bounds.width().max(bounds.height()); if size <= 0.0 { return Ok(None); } @@ -312,7 +320,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } if o.depth > 0.0 { // The sides, cut into slices that follow a twist or taper. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let slices = slices(&o); let at = |ring: &[Point], k: usize| -> Vec { let t = k as f64 / slices as f64; let turn = Turn::new(bounds, o.twist, o.taper, t); @@ -320,6 +328,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 81e67cc03..02fb6f406 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,8 +1,7 @@ -use super::{FillRule, ParamCurve, PathData, Point, Rect}; +use super::{FillRule, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect}; -/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel -/// bands and distance tests. -pub(super) const MAX_SOLID_POINTS: usize = 2048; +/// Outline points Extrude & Bevel and Inflate sample at most, and contours they take at most. +pub(super) const MAX_SOLID_POINTS: usize = 65536; /// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. pub(super) const MAX_PLANE_POINTS: usize = 65536; @@ -10,13 +9,76 @@ pub(super) const MAX_PLANE_POINTS: usize = 65536; /// Pieces a twisted wall's strip is cut into along one edge at most. pub(super) const MAX_PIECES: usize = 64; +/// Pieces a curve takes at most, where its tolerance asks for them. +pub(super) const CURVE_PIECES: u32 = 32; + +/// The fewest pieces a curve of a solid over its budget is held to: a curve whose tolerance asks +/// for fewer takes fewer. +pub(super) const MIN_CURVE_PIECES: u32 = 4; + +const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; +pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from -/// its chords), 1 to 32. +/// its chords), 1 to [`CURVE_PIECES`]. fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); let n = (0.75 * bend / tolerance).sqrt().ceil(); - if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } + if n.is_finite() { (n as u32).clamp(1, CURVE_PIECES) } else { CURVE_PIECES } +} + +/// The most pieces each curve of a solid's outline takes, counted on `path` before any point is +/// sampled: [`CURVE_PIECES`] when the outline fits within [`MAX_SOLID_POINTS`] points and +/// [`MAX_SOLID_FACES`] faces, else the largest number down to [`MIN_CURVE_PIECES`] that fits, the +/// same for every curve. A curve whose tolerance (see [`sample`]) asks for fewer pieces takes +/// fewer. The faces are counted as `per_edge` for each edge of the outline and `fixed` more. +/// +/// An error when the outline takes more than [`MAX_SOLID_POINTS`] points, or contours, with +/// every curve held to [`MIN_CURVE_PIECES`], and when its faces do not fit with that many. +pub(super) fn curve_pieces(path: &PathData, size: f64, per_edge: usize, fixed: usize) -> Result { + let tolerance = (size / 2000.0).max(0.05); + // How many curves ask for each number of pieces. + let mut curves = [0usize; CURVE_PIECES as usize + 1]; + let (mut lines, mut segments, mut open) = (0usize, 0usize, 0usize); + for (contours, sp) in path.subpaths.iter().enumerate() { + if contours >= MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // A subpath of one anchor draws nothing, as in [`sample`]. + if sp.anchors.len() < 2 { + continue; + } + if sp.anchors.iter().any(|a| [a.p, a.h_in, a.h_out].iter().any(|q| !q.x.is_finite() || !q.y.is_finite())) { + return Err("The shape contains invalid coordinates".into()); + } + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; + // Every segment takes a point at least. + segments = segments.saturating_add(count); + if segments > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + open += usize::from(!sp.closed); + for i in 0..count { + if sp.segment_is_line(i) { + lines += 1; + } else if let Some(n) = curves.get_mut(pieces(sp.segment(i), tolerance) as usize) { + *n += 1; + } + } + } + let points = |most: u32| -> usize { + curves.iter().enumerate().fold(lines, |sum, (asked, n)| sum.saturating_add(n.saturating_mul(asked.min(most as usize)))) + }; + if points(MIN_CURVE_PIECES) > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // An open contour is closed by one more edge. + let faces = |most: u32| points(most).saturating_add(open).saturating_mul(per_edge).saturating_add(fixed); + (MIN_CURVE_PIECES..=CURVE_PIECES) + .rev() + .find(|most| points(*most) <= MAX_SOLID_POINTS && faces(*most) <= MAX_SOLID_FACES) + .ok_or_else(|| TOO_COMPLEX_SURFACE.into()) } /// Contours thinner than this on average (twice their area over their perimeter) are dropped when @@ -37,14 +99,15 @@ pub(super) const DEBRIS: f64 = 1e-6; /// are. A profile the boolean refuses is built from its contours as drawn, as it was before /// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice /// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. -pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - let sampled = sample(path, size, limit)?; +/// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result>, String> { + let sampled = sample(path, size, limit, most)?; let closed = closed_straight(path); if !meets(&sampled, &closed) { return orient(sampled, rule); } match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { - Ok(merged) => sample(&merged, size, limit).map(wound), + Ok(merged) => sample(&merged, size, limit, most).map(wound), Err(_) => orient(sampled, rule), } } @@ -197,12 +260,12 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { /// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would /// if nesting alone decided (#1724). pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { - orient(sample(path, size, limit)?, rule) + orient(sample(path, size, limit, CURVE_PIECES)?, rule) } /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as -/// its subpath does. -pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// its subpath does, each curve in at most `most` pieces. +pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -210,7 +273,7 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result= 2) { let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; if segments > limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } let mut ring: Vec = vec![]; if let Some(first) = sp.anchors.first() { @@ -218,10 +281,10 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { ring.push(curve.eval(f64::from(k) / f64::from(samples))); @@ -252,7 +315,7 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). - let too_complex = || String::from("The shape is too complex for a 3D effect"); + let too_complex = || String::from(TOO_COMPLEX_SHAPE); let mut work = 0usize; let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 46d178287..385800623 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -277,11 +277,14 @@ fn malformed_and_degenerate_input_is_bounded() { // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); - for i in 0..3000 { + for i in 0..70_000 { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); + assert_eq!( + extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap_err(), + "The shape is too complex for a 3D effect" + ); } #[test] @@ -374,8 +377,7 @@ fn bumps(count: usize) -> (PathData, Rect) { } /// Curves are followed within a tolerance rather than by sixteen points each: small curves take a -/// few points, so shapes of many curves (outlined type) fit; Rotate, which only turns the outline, -/// takes far more. +/// few points, so shapes of many curves (outlined type) fit, in a solid and in Rotate. #[test] fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. @@ -383,10 +385,13 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); - // 4,000 curves: too many for a solid, not for Rotate. + // 4,000 curves fit a solid and Rotate alike; 70,000 fit neither. let (p, r) = bumps(4000); - assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); + let (p, r) = bumps(70_000); + assert_eq!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect"); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap_err(), "The shape is too complex for a 3D effect"); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); @@ -1019,7 +1024,7 @@ fn flat_far_and_too_large_merges() { let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect(); let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false); let with_line = PathData::new(squares.iter().cloned().chain([line]).collect()); - assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line))); + assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(&with_line))); assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6)); // Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the // test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas. @@ -1049,9 +1054,8 @@ fn flat_far_and_too_large_merges() { let b = wide.bounds().unwrap(); let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap(); assert_eq!(joined.iter().map(|f| f.contours.len()).sum::(), 2, "the squares joined, the lobe apart"); - // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced: - // past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so - // they can't be built as they are), and Rotate's 65,536 takes it. + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced, + // within the 65,536 points Extrude & Bevel, Inflate and Rotate take. let mut bars = vec![]; for i in 0..24 { let x = i as f64 * 10.0; @@ -1061,9 +1065,18 @@ fn flat_far_and_too_large_merges() { let grid = PathData::new(bars); let b = grid.bounds().unwrap(); for rule in [FillRule::NonZero, FillRule::EvenOdd] { - assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); - assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate"); + // With a limit of 2,048 points the grid fits as drawn and its merged outline does not: its + // contours as drawn cross, so they can't be built as they are. + assert!(rings::sample(&grid, 240.0, 2048, rings::CURVE_PIECES).is_ok()); + assert_eq!( + rings::solid_rings(&grid, rule, 240.0, 2048, rings::CURVE_PIECES), + Err("The shape is too complex for a 3D effect".into()), + "{rule:?}" + ); + let expected = vectorcraft_pathops::area(&grid, rule); + assert!(close(area(&extrude(&grid, rule, b, Extrude::default(), front()).unwrap()), expected, 1e-6), "{rule:?}: Extrude & Bevel"); + assert!(!inflate(&grid, rule, b, Inflate::default(), front()).unwrap().is_empty(), "{rule:?}: Inflate"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), expected, 1e-6), "{rule:?}: Rotate"); } } @@ -1076,7 +1089,7 @@ fn meets_within_twice_the_boolean_tolerance() { let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) }; - let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p)); + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(p)); // Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on // the page (greater y) than the first's: snapped onto the first's heights, they are joined. let d = 0.99e-6; diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs new file mode 100644 index 000000000..350b8dd95 --- /dev/null +++ b/crates/three-d/src/solid/tests_limits.rs @@ -0,0 +1,141 @@ +use vectorcraft_geom::{Anchor, SubPath}; + +use super::*; + +const SHAPE: &str = "The shape is too complex for a 3D effect"; +const SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + +/// A closed ring of `count` curved petals around a circle of `radius`: each curve leaves its anchor +/// outward, `lift` points out, and comes back to the next anchor, so a larger lift asks for more +/// pieces per curve. +fn petals(count: usize, radius: f64, lift: f64) -> PathData { + let chord = std::f64::consts::TAU * radius / count as f64; + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + let p = Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s); + let (out, along) = (Vec2::new(c, s) * lift, Vec2::new(-s, c) * (chord / 3.0)); + Anchor::with_handles(p, p + out - along, p + out + along) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// A closed polygon of `count` straight edges around a circle. +fn polygon(count: usize, radius: f64) -> PathData { + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + Anchor::corner(Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// Turned so that the walls and bevels show. +fn turned() -> View { + View { rotation_x: -18.0, rotation_y: -26.0, rotation_z: 8.0, ..View::default() } +} + +fn size(path: &PathData) -> f64 { + let b = path.bounds().unwrap(); + b.width().max(b.height()) +} + +/// How many points the outline samples to, each curve in at most `most` pieces. +fn points(path: &PathData, most: u32) -> usize { + rings::sample(path, size(path), usize::MAX, most).unwrap().iter().map(Vec::len).sum() +} + +fn built(facets: &[Facet]) -> bool { + !facets.is_empty() + && facets.len() <= MAX_SOLID_FACES + && facets.iter().flat_map(|f| f.contours.iter().flatten()).all(|p| p.x.is_finite() && p.y.is_finite()) +} + +#[test] +fn two_thousand_curves_extrude_with_a_bevel_and_inflate() { + let path = petals(2000, 500.0, 4.0); + let b = path.bounds().unwrap(); + // Every curve takes several pieces: far more points than 2,048, all of them kept. + let sampled = points(&path, rings::CURVE_PIECES); + assert!(sampled >= 3 * 2000, "{sampled}"); + assert_eq!(rings::curve_pieces(&path, size(&path), 2, 2), Ok(rings::CURVE_PIECES)); + let bevel = Extrude { bevel: Some(Bevel::default()), ..Extrude::default() }; + let extruded = extrude(&path, FillRule::NonZero, b, bevel, turned()).unwrap(); + assert!(built(&extruded) && extruded.len() > sampled, "{}", extruded.len()); + let inflated = inflate(&path, FillRule::NonZero, b, Inflate::default(), turned()).unwrap(); + assert!(built(&inflated), "{}", inflated.len()); +} + +#[test] +fn an_outline_at_the_point_limit_is_taken_and_one_past_it_is_refused_before_sampling() { + let at = polygon(MAX_SOLID_POINTS, 20_000.0); + assert_eq!(rings::curve_pieces(&at, size(&at), 1, 2), Ok(rings::CURVE_PIECES)); + assert!(built(&extrude(&at, FillRule::NonZero, at.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let past = polygon(MAX_SOLID_POINTS + 1, 20_000.0); + assert_eq!(rings::curve_pieces(&past, size(&past), 1, 2), Err(SHAPE.to_string())); + assert_eq!(extrude(&past, FillRule::NonZero, past.bounds().unwrap(), Extrude::default(), turned()), Err(SHAPE.to_string())); + assert_eq!(inflate(&past, FillRule::NonZero, past.bounds().unwrap(), Inflate::default(), turned()), Err(SHAPE.to_string())); +} + +#[test] +fn faces_at_the_limit_are_taken_and_one_past_it_is_refused_before_sampling() { + // 40,960 straight edges in four bands are exactly the face limit. + let path = polygon(MAX_SOLID_FACES / 4, 20_000.0); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 0), Ok(rings::CURVE_PIECES)); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 1), Err(SURFACE.to_string())); + // A rounded bevel gives 20,000 edges nine bands each, past the limit. + let path = polygon(20_000, 20_000.0); + let rounded = Extrude { bevel: Some(Bevel { shape: BevelShape::Rounded, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&path, FillRule::NonZero, path.bounds().unwrap(), rounded, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn curves_over_the_budget_take_fewer_pieces_down_to_four() { + let path = petals(2000, 500.0, 20.0); + let s = size(&path); + assert_eq!(points(&path, rings::CURVE_PIECES), 6 * 2000, "six pieces a curve within the tolerance"); + // 16 faces an edge: 12,000 points are too many, 10,000 at five pieces a curve fit. + assert_eq!(rings::curve_pieces(&path, s, 16, 0), Ok(5)); + assert_eq!(points(&path, 5), 5 * 2000); + // At four pieces a curve, 21 faces an edge are still too many. + assert_eq!(rings::curve_pieces(&path, s, 21, 0), Err(SURFACE.to_string())); + // Twisted 96°, the walls are 16 slices: built with five pieces a curve. + let b = path.bounds().unwrap(); + let twisted = extrude(&path, FillRule::NonZero, b, Extrude { twist: 96.0, hidden: true, ..Extrude::default() }, turned()).unwrap(); + assert!(built(&twisted) && twisted.len() > MAX_FACES, "{}", twisted.len()); + // Twisted 360°, 60 slices are too many even at four pieces a curve. + assert_eq!(extrude(&path, FillRule::NonZero, b, Extrude { twist: 360.0, ..Extrude::default() }, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn five_and_ten_thousand_curves_build_at_the_defaults() { + let five = petals(5000, 1250.0, 2.0); + assert!(built(&inflate(&five, FillRule::NonZero, five.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(built(&extrude(&five, FillRule::NonZero, five.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let ten = petals(10_000, 2500.0, 4.0); + let b = ten.bounds().unwrap(); + assert!(points(&ten, rings::CURVE_PIECES) > 10_000); + assert!(built(&extrude(&ten, FillRule::NonZero, b, Extrude::default(), turned()).unwrap())); + let deep = Extrude { twist: 360.0, bevel: Some(Bevel { shape: BevelShape::Rounded, repeat: 4, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&ten, FillRule::NonZero, b, deep, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { + // Contours of one anchor and of none draw nothing, yet each is counted. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(1.0, i as f64))] }, false); + let square = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true); + let at = PathData::new((0..MAX_SOLID_POINTS - 1).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&at, 10.0, 1, 2), Ok(rings::CURVE_PIECES)); + let past = PathData::new((0..MAX_SOLID_POINTS).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&past, 10.0, 1, 2), Err(SHAPE.to_string())); + let b = Rect::new(0.0, 0.0, 10.0, 10.0); + assert_eq!(extrude(&past, FillRule::NonZero, b, Extrude::default(), turned()), Err(SHAPE.to_string())); + let mut bad = square; + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let bad = PathData::new(vec![bad]); + assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); + assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); +} diff --git a/docs/mcp.md b/docs/mcp.md index a80d25f0b..89466afb7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2578,6 +2578,10 @@ solid, false open behind), `volume` (0..100%, default 50), `bothSides`. All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06), `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +`threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid +(VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the +points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns +"The 3D surface is too complex; simplify the shape". `threeD.rotate` takes up to 65,536 points. ## Extrude & Bevel (Classic) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8bd43ad5..19ee2abea 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1058,6 +1058,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). diff --git a/docs/three-d.md b/docs/three-d.md index 0e9901061..e9c8c5e32 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -105,7 +105,8 @@ The rest is our reading: - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat. -- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +- A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out, + and the fill's solid still draws. ## Groups @@ -235,10 +236,15 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about one axis ([revolve.md](revolve.md)). One 3D effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points - and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is - counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, - when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace - every hole between them); past it, the shape is too complex for the effect. + and a large one up to 32. Extrude & Bevel and Inflate take up to 65,536 outline points and 163,840 faces for each + solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points. + These limits are VectorCraft's own values. Before any point is sampled, Extrude & Bevel counts the faces its walls, + bevels and caps need, and Inflate counts the faces of its sides; when they do not fit, every curve of the shape + takes fewer pieces, down to 4 (a curve that needs fewer takes fewer). A shape that does not fit at 4 pieces a + curve is refused because the 3D surface is too complex. Inflate counts the faces of its front and back as it + builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours + meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between + them); past it, the shape is too complex for the effect. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. From af0d01308f6285843e1ee05e8f2c30293fb8a2af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:39:48 -0800 Subject: [PATCH 13/14] Scripts: a framed run keeps the file-access roots it started under A script.run that a control request started in the app's frames was advanced by later frames, which run unconfined, so its vc.run file commands could read and write outside the caller's automation roots. The job now keeps the file-access scope of its script.run and runs each slice in it, as background opening and the folder walker do. --- crates/engine/src/script/mod.rs | 23 ++++++++++------ crates/engine/src/tests/script_run.rs | 38 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 35e93c1ad..97b5f1889 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -502,6 +502,8 @@ struct Job { /// The documents open when the run began, by [`DocState::uid`]. start_uids: Vec, name: String, + /// The file-access scope `script.run` came in, which every slice runs in. + scope: crate::file_access::ScopeHandle, } /// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in @@ -986,7 +988,8 @@ impl Session { self.script_report = Some(report); return Err(EngineError::Other(message)); } - let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name }; + let scope = crate::file_access::ScopeHandle::capture(); + let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); self.script_key = Some(JobKey(id)); @@ -1170,20 +1173,24 @@ fn failure_text(report: &Value) -> String { } /// A run's slice: the working copy in, the real session lent to the host functions, one measured -/// slice run, and everything back. A panic in the interpreter is the outer `Err`. +/// slice run in the file-access scope the run began in, and everything back. A panic in the +/// interpreter is the outer `Err`. fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result, String> { let depth = std::mem::replace(&mut s.depth, 0); job.world.swap(s); s.script_in_run = true; let cell = job.host.session.clone(); std::mem::swap(s, &mut *cell.borrow_mut()); + let scope = job.scope.clone(); let realm = &mut job.realm; - let r = guard::catch_panic(|| { - #[cfg(test)] - if PANIC_IN_SLICE.with(Cell::get) { - panic!("a slice failed"); - } - realm.advance(SLICE_FUEL) + let r = scope.run(|| { + guard::catch_panic(|| { + #[cfg(test)] + if PANIC_IN_SLICE.with(Cell::get) { + panic!("a slice failed"); + } + realm.advance(SLICE_FUEL) + }) }); std::mem::swap(s, &mut *cell.borrow_mut()); s.script_in_run = false; diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 5480ba499..eba254116 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -862,6 +862,44 @@ fn a_file_saved_before_a_failure_stays_written_and_is_named() { let _ = std::fs::remove_file(&path); } +/// A run in the app's frames keeps the file-access scope its `script.run` came in, whatever scope +/// the frames that advance it run in. +#[test] +fn a_framed_run_keeps_the_file_access_scope_it_started_in() { + let dir = std::env::temp_dir().join(format!("vc-script-scope-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (allowed, outside) = (dir.join("allowed"), dir.join("outside")); + std::fs::create_dir_all(&allowed).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&allowed], &[&allowed]).unwrap().unwrap(); + let export = |target: &std::path::Path| { + let p = target.to_string_lossy().into_owned(); + format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); vc.log('ok'); }} catch (e) {{ vc.log(e.name); }}") + }; + let finish = |s: &mut Session| { + loop { + if let Some(r) = s.script_pump() { + return r; + } + } + }; + let mut s = two_docs(); + s.script_frames = true; + let confined_target = outside.join("confined.svg"); + let started = crate::file_access::confine(Some(&roots), || run(&mut s, &export(&confined_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::unconfined(|| finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["Error"]), "a confined caller's run stays confined: {r}"); + assert!(!confined_target.exists()); + let user_target = outside.join("user.svg"); + let started = crate::file_access::unconfined(|| run(&mut s, &export(&user_target))).unwrap(); + assert_eq!(started["running"], true); + let r = crate::file_access::confine(Some(&roots), || finish(&mut s)).unwrap(); + assert_eq!(r["log"], json!(["ok"]), "a run the person started is not confined by a later caller: {r}"); + assert!(user_target.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_committed_run_exports_what_it_drew() { let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id())); From a900653c9c1b4f2e34f52cf5df7232e4a9c70425 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 10:42:12 -0800 Subject: [PATCH 14/14] 3D: Inflate limits the work of filling its grid Inflate's grid clipped every ring of the outline in each cell the outline crosses and tested every edge for each cell's center, so its work grew with the outline's points times the cells: a comb of 4,000 teeth (16,002 points) took 1.1 to 1.3 s and one of 16,000 teeth 8.8 to 9.1 s. Inflate now counts that work (the edge index's entries, the edges its queries look at and the ring points its cells clip) and refuses the shape with "The 3D surface is too complex; simplify the shape" once it passes 268,435,456, VectorCraft's own value (about 0.65 s). The index takes at most 16,777,216 entries (64 MiB). Each row's inside test reads the sorted crossings of its line, and a cell clips only the rings whose bounds meet it, so a 65,536-point circle inflates in 0.66 s instead of 1.0 to 1.2 s. The effects test of a stroke past the limits uses 40,000 corners, so its outline passes the 65,536 points a solid takes. Docs: docs/three-d.md, docs/mcp.md. --- crates/effects/src/solids/tests.rs | 10 +- crates/three-d/src/solid.rs | 4 +- crates/three-d/src/solid/inflate.rs | 53 ++++++++- crates/three-d/src/solid/membrane.rs | 132 ++++++++++++++++++----- crates/three-d/src/solid/tests.rs | 8 +- crates/three-d/src/solid/tests_limits.rs | 56 ++++++++++ docs/mcp.md | 4 +- docs/three-d.md | 4 + 8 files changed, 228 insertions(+), 43 deletions(-) diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 29bbdb3bd..475868d6f 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() { /// still draws. #[test] fn a_stroke_past_the_limits_is_left_out() { - // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. - let points: Vec = (0..1500) + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) .map(|k| { - let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); - let r = if k % 2 == 0 { 100.0 } else { 96.0 }; - Point::new(200.0 + r * c, 200.0 + r * s) + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) }) .collect(); let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f4e11270e..339b148a5 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -23,11 +23,13 @@ use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; #[cfg(test)] +use inflate::GRID_WORK; +#[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] -use membrane::{Edges, Membrane, cell_triangles}; +use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index b3de81e61..20242697f 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,5 +1,5 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; -use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; +use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -43,8 +43,11 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result = const { std::cell::Cell::new(0) }; + /// The grid work of the last solid this thread inflated in full, for the tests. + pub(super) static GRID_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the @@ -151,14 +156,41 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo // builds the topology of every outline ring near its cell, so a self-crossing outline cost its // crossings again in every cell it spans. Past it, cells take their clipped pieces. let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); - // The shape's part of a rectangle, as rings. + // Every ring's bounds, and the crossings of the last few rows the cells' centres lie on (a + // row of cells has at most seven, at its three levels). + let ring_bounds: Vec> = + rings.iter().map(|r| Some(r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)))).collect(); + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let rows: std::cell::RefCell)>> = std::cell::RefCell::new(vec![]); + let filled_at = |p: Point| -> bool { + let mut rows = rows.borrow_mut(); + if let Some((_, xs)) = rows.iter().find(|(y, _)| *y == p.y.to_bits()) { + return filled(xs, p.x); + } + let xs = if edges.charge(points) { crossings(&rings, p.y) } else { vec![] }; + let inside = filled(&xs, p.x); + if rows.len() >= 8 { + rows.remove(0); + } + rows.push((p.y.to_bits(), xs)); + inside + }; + // The shape's part of a rectangle, as rings: only the rings whose bounds meet it are clipped, + // each point counted as work. let cut = |r: Rect| -> Vec> { // A cell no edge crosses is wholly inside the shape or wholly outside it. if !edges.touch(r) { let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; + return if filled_at(r.center()) { vec![corners] } else { vec![] }; } - let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); + let pieces: Vec> = rings + .iter() + .zip(&ring_bounds) + .filter(|(_, b)| b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)) + .filter(|(ring, _)| edges.charge(ring.len())) + .map(|(ring, _)| clip(ring, r)) + .filter(|c| !c.is_empty()) + .collect(); if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { return vec![]; } @@ -193,6 +225,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } }) .collect(); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } let level = |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; for j in 0..ny { @@ -209,6 +244,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo for sx in (bx0..bx1).step_by(step) { let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); let pieces = cut(r); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } if pieces.is_empty() { continue; } @@ -338,5 +376,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } } } + #[cfg(test)] + GRID_WORK.with(|w| w.set(edges.work())); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } Ok(b.finish()) } diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 02d258531..e8a855e29 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,9 +1,35 @@ +use std::cell::Cell; + use super::build::segment_distance; -use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; +use super::rings::{MAX_PLANE_POINTS, TOO_COMPLEX_SURFACE, left, rings, signed_area}; use super::{FillRule, PathData, Point, Rect, Vec2}; +/// Most entries the edge index of one inflated shape holds: one for each bucket an edge's bounds +/// cover (64 MiB). +pub(super) const MAX_EDGE_ENTRIES: usize = 1 << 24; + +/// Most work the grid of one inflated shape takes: the index's entries, the edges its queries +/// look at, the crossings of the rows it fills and the ring points its cells clip, together. +pub(super) const MAX_GRID_WORK: usize = 1 << 28; + +#[cfg(test)] +thread_local! { + /// The grid work the next edge indexes of this thread may take, for the tests. + pub(super) static GRID_BUDGET: Cell = const { Cell::new(MAX_GRID_WORK) }; +} + +/// The grid work a new edge index may take. +fn grid_budget() -> usize { + #[cfg(test)] + return GRID_BUDGET.with(Cell::get); + #[cfg(not(test))] + MAX_GRID_WORK +} + /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is -/// found among the buckets around it rather than among all the edges. +/// found among the buckets around it rather than among all the edges. It also counts the work +/// done with it (see [`MAX_GRID_WORK`]): once that is spent, its buckets read as empty, and the +/// caller refuses the shape. pub(super) struct Edges { edges: Vec<(Point, Point)>, normals: Vec<(Vec2, Vec2)>, @@ -11,10 +37,14 @@ pub(super) struct Edges { size: f64, n: usize, buckets: Vec>, + work: Cell, + budget: usize, } impl Edges { - pub(super) fn new(rings: &[Vec]) -> Self { + /// The index of `rings`' edges; an error when it would hold more than [`MAX_EDGE_ENTRIES`] + /// entries, counted before any is filed. + pub(super) fn new(rings: &[Vec]) -> Result { const N: usize = 64; let edges: Vec<(Point, Point)> = rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); @@ -37,16 +67,48 @@ impl Edges { let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); let mut buckets = vec![vec![]; N * N]; let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); - for (k, (a, b)) in edges.iter().enumerate() { - let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); - let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); + let spans = |(a, b): &(Point, Point)| { + ((index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)), (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0))) + }; + let entries = edges.iter().map(spans).fold(0usize, |sum, ((i0, i1), (j0, j1))| sum.saturating_add((i1 - i0 + 1) * (j1 - j0 + 1))); + if entries > MAX_EDGE_ENTRIES { + return Err(TOO_COMPLEX_SURFACE.into()); + } + for (k, e) in edges.iter().enumerate() { + let ((i0, i1), (j0, j1)) = spans(e); for j in j0..=j1 { for i in i0..=i1 { buckets[j * N + i].push(k as u32); } } } - Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } + Ok(Self { edges, normals, origin: bounds.origin(), size, n: N, buckets, work: Cell::new(entries), budget: grid_budget() }) + } + + /// Has the work done with the index gone past its budget? + pub(super) fn spent(&self) -> bool { + self.work.get() > self.budget + } + + /// The work done with the index so far. + #[cfg(test)] + pub(super) fn work(&self) -> usize { + self.work.get() + } + + /// Count `amount` more work; `false` once the budget is spent, when the work is not to be done. + pub(super) fn charge(&self, amount: usize) -> bool { + if self.spent() { + return false; + } + self.work.set(self.work.get().saturating_add(amount)); + !self.spent() + } + + /// The edges filed in bucket (`i`, `j`), counted as work; none once the budget is spent. + fn bucket(&self, i: isize, j: isize) -> &[u32] { + let bucket = self.buckets.get((j * self.n as isize + i) as usize).map_or(&[][..], Vec::as_slice); + if self.charge(bucket.len()) { bucket } else { &[] } } /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. @@ -58,15 +120,11 @@ impl Edges { /// looked at. pub(super) fn near(&self, p: Point, tolerance: f64) -> bool { self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { - let n = self.n as isize; let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) + self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)).any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) }) }) } @@ -75,10 +133,9 @@ impl Edges { /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the /// buckets `r` covers comes more than once). fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { - let n = self.n as isize; for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { - for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { + for (a, b) in self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)) { if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { f(*a, *b); } @@ -89,12 +146,11 @@ impl Edges { /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? pub(super) fn touch(&self, r: Rect) -> bool { - let n = self.n as isize; let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] + self.bucket(i, j) .iter() .filter_map(|k| self.edges.get(*k as usize)) .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) @@ -122,12 +178,15 @@ impl Edges { let mut best = f64::INFINITY; let mut nearest = (p, Vec2::ZERO); for r in 0..n { + if self.spent() { + break; + } for j in (cj - r)..=(cj + r) { for i in (ci - r)..=(ci + r) { if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { continue; } - for k in &self.buckets[(j * n + i) as usize] { + for k in self.bucket(i, j) { if let Some((a, b)) = self.edges.get(*k as usize) { let d = *b - *a; let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; @@ -152,9 +211,24 @@ impl Edges { } } -/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? -pub(super) fn filled(rings: &[Vec], p: Point) -> bool { - rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 +/// Where the edges of `rings` cross the line through `y`, in order: the points of the line +/// filled by `rings` (nonzero winding, as they are oriented) are those with an odd number of +/// crossings to their right ([`filled`]). An edge crosses where one end is above the line and +/// the other is not, as [`super::rings::contains`] counts it. +pub(super) fn crossings(rings: &[Vec], y: f64) -> Vec { + let mut xs: Vec = rings + .iter() + .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) + .filter(|(a, b)| (a.y > y) != (b.y > y)) + .map(|(a, b)| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +/// Is `x` filled on the line whose [`crossings`] are `xs`? +pub(super) fn filled(xs: &[f64], x: f64) -> bool { + (xs.len() - xs.partition_point(|v| *v <= x)) % 2 == 1 } /// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring @@ -203,13 +277,17 @@ impl Membrane { pub(super) fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); let idx = |i: usize, j: usize| j * nx + i; - // A node on the outline itself is held at zero with the outside. - let inside: Vec = (0..nx * ny) - .map(|k| { - let p = node(k % nx, k / nx); - filled(rings, p) && !edges.near(p, 1e-6 * step) - }) - .collect(); + // A node on the outline itself is held at zero with the outside. Each row of nodes is + // tested against the crossings of its line. + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let mut inside = Vec::with_capacity(nx * ny); + for j in 0..ny { + let xs = if edges.charge(points) { crossings(rings, node(0, j).y) } else { vec![] }; + inside.extend((0..nx).map(|i| { + let p = node(i, j); + filled(&xs, p.x) && !edges.near(p, 1e-6 * step) + })); + } // The nearest crossing of the outline from `p` along (dx, dy), within one step. let crossing = |p: Point, dx: f64, dy: f64| -> f64 { let mut best = step; diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 385800623..f3efe8455 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -324,11 +324,11 @@ fn the_inflate_membrane_matches_known_solutions() { c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let disc = Membrane::solve(&r, &Edges::new(&r), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let sq = Membrane::solve(&r, &Edges::new(&r), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -426,7 +426,7 @@ fn twisted_pieces_stay_within_the_facet_limit() { fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); for k in 0..400 { let t = f64::from(k); let q = Point::new(-300.0 + (t * 37.0) % 1400.0, -50.0 + (t * 13.0) % 130.0); @@ -442,7 +442,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); let q = Point::new(-10.0, 0.5); let (fast, slow) = (edges.distance(q), edge_distance(&rings, q)); assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}"); diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs index 350b8dd95..bd7009623 100644 --- a/crates/three-d/src/solid/tests_limits.rs +++ b/crates/three-d/src/solid/tests_limits.rs @@ -139,3 +139,59 @@ fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); } + +/// A comb of `teeth` teeth, 100 wide and 100 tall, on a strip along its foot: its outline +/// crosses every cell of the grid Inflate fills. +fn comb(teeth: usize) -> PathData { + let w = 100.0 / teeth as f64; + let mut points: Vec = (0..teeth) + .flat_map(|k| { + let x = k as f64 * w; + [Point::new(x, 0.0), Point::new(x, 100.0), Point::new(x + w / 2.0, 100.0), Point::new(x + w / 2.0, 0.0)] + }) + .collect(); + points.extend([Point::new(100.0, -1.0), Point::new(0.0, -1.0)]); + PathData::new(vec![SubPath::polyline(&points, true)]) +} + +/// Inflate `path` with the grid work its edge index may take set to `budget`. +fn inflated_within(path: &PathData, budget: usize) -> Result, String> { + GRID_BUDGET.with(|b| b.set(budget)); + let out = inflate(path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()); + GRID_BUDGET.with(|b| b.set(MAX_GRID_WORK)); + out +} + +#[test] +fn inflate_at_its_grid_work_budget_builds_and_one_past_it_is_refused() { + let path = comb(200); + assert!(built(&inflated_within(&path, MAX_GRID_WORK).unwrap())); + let work = GRID_WORK.with(std::cell::Cell::get); + assert!(work > 0); + assert!(built(&inflated_within(&path, work).unwrap())); + assert_eq!(GRID_WORK.with(std::cell::Cell::get), work, "the same work at its budget"); + assert_eq!(inflated_within(&path, work - 1), Err(SURFACE.to_string())); +} + +#[test] +fn the_edge_index_at_its_entry_limit_is_built_and_one_past_it_is_refused() { + // A diagonal across the whole grid of 64 by 64 buckets is filed in every bucket, each way. + let diagonal = vec![Point::new(0.0, 0.0), Point::new(64.0, 64.0)]; + assert_eq!(MAX_EDGE_ENTRIES, 2048 * 2 * 64 * 64); + let mut rings = vec![diagonal; 2048]; + assert!(Edges::new(&rings).is_ok_and(|e| e.work() == MAX_EDGE_ENTRIES)); + // A small triangle in one bucket: three entries more. + rings.push(vec![Point::new(0.0, 0.0), Point::new(0.5, 0.0), Point::new(0.0, 0.5)]); + assert_eq!(Edges::new(&rings).err(), Some(SURFACE.to_string())); +} + +#[test] +fn a_comb_that_crosses_every_cell_is_refused_once_its_grid_work_is_spent() { + // Two thousand curves take a small share of the budget. + let curves = petals(2000, 500.0, 4.0); + assert!(built(&inflate(&curves, FillRule::NonZero, curves.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(GRID_WORK.with(std::cell::Cell::get) < MAX_GRID_WORK / 16); + // 16,002 points, each clipped in every cell its ring meets. + let path = comb(4000); + assert_eq!(inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()), Err(SURFACE.to_string())); +} diff --git a/docs/mcp.md b/docs/mcp.md index 89466afb7..9f9b99a4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2581,7 +2581,9 @@ the scope and what differs from the public documentation. `threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid (VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns -"The 3D surface is too complex; simplify the shape". `threeD.rotate` takes up to 65,536 points. +"The 3D surface is too complex; simplify the shape". `threeD.inflate` also returns the surface error when its outline +has so many points across so many of its grid's cells that filling the grid would pass its work limit. `threeD.rotate` +takes up to 65,536 points. ## Extrude & Bevel (Classic) diff --git a/docs/three-d.md b/docs/three-d.md index e9c8c5e32..e541a078a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -245,6 +245,10 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between them); past it, the shape is too complex for the effect. +- Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its + outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth + across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a + circle builds. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items.