From 81d19f171842d04d39b00dca0929185aa0a67cce Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 08:56:50 -0800 Subject: [PATCH 1/3] Edit Colors: convert embedded images to indexed color Edit > Edit Colors > Convert to Indexed Color... (edit.colors.toIndexed) stores the embedded images in the selection as indexed-color PNGs: a palette of at most 256 RGB colors, each pixel the index of one. The dialog previews live and OK is one undo step. - The palette is chosen as for a PNG-8 export (selective, perceptual, adaptive, web, gray, black and white), as evenly spaced levels of red, green and blue (uniform), or from the document's swatches. Dithering is diffusion, pattern, noise or none, with an amount. - Pixels under half opacity become one transparent entry; the others become opaque in their own color. Linked images, colorized images and color glyph images keep their pixels. - An image that is already an indexed PNG and would show the same pixels is left as it is, and no undo step is recorded when no image changes. - An image of more than 134,217,728 pixels (33,554,432 in the web app) is refused, read from its header before decoding. - With Select All, locked and hidden objects that hold an image that is not an indexed PNG yet are counted in notChanged, without converting them. - The quantizer gains quantize_to, which maps pixels to a given palette. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/pixels.rs | 21 ++ crates/engine/src/cmd/colorcmds.rs | 214 ++++++++++++++- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/editcolors.rs | 244 ++++++++++++++++++ .../engine/src/tests/ids_without_selection.rs | 1 + crates/mcp/src/ui_catalog.rs | 6 + crates/render/src/encode/quantize.rs | 51 +++- crates/render/src/encode/tests_palette.rs | 18 +- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/indexed_color.rs | 135 ++++++++++ crates/ui-egui/src/dialogs/tests.rs | 1 + 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/run.rs | 4 + crates/ui-egui/src/menus/state.rs | 2 +- crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + docs/mcp.md | 31 ++- docs/target-app-parity.md | 1 + 44 files changed, 770 insertions(+), 23 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/indexed_color.rs diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..1c231f7bbe 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -194,6 +194,27 @@ impl ImageBlob { .collect(); Some((w, h, cover)) } + + /// The pixels as straight RGBA, row by row from the top, with the width and the height; + /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn rgba(&self) -> Option<(u32, u32, Vec)> { + let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; + let (w, h) = img.dimensions(); + Some((w, h, img.into_raw())) + } + + /// The width and height in pixels the image's file says it has; `None` for data that isn't an + /// image the decoder reads. Reads only its header. + pub fn pixel_size(&self) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok() + } + + /// Whether the image's file is a PNG that stores palette indices (an indexed-color image). + /// Reads only its header. + pub fn indexed_png(&self) -> bool { + // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type. + self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3) + } } impl ImageBlob { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..b2b44a898d 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -3,7 +3,11 @@ //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. +//! +//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as +//! a palette PNG. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; pub(crate) use vectorcraft_doc::recolor::map_paint; use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; -use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind}; +use vectorcraft_render::encode::png::{PngOptions, encode_indexed}; +use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::edit::selected_roots; use super::{ @@ -66,6 +72,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "edit.colors.toIndexed", + "Convert to Indexed Color…", + ["Edit", "Edit Colors"], + None, + "{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)", + has_selection, + to_indexed + ), cmd!( "edit.colors.saturate", "Saturate…", @@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is. +type Remake<'a> = dyn Fn(&ImageBlob) -> Option + 'a; + +/// The objects a pass recolors, and the locked and hidden objects the selection left out, each +/// with whether it is hidden ([`pass_targets`]). +type Targets = (Vec, Vec<(NodeId, bool)>); + /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> { scope: Scope, /// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]). gray_images: bool, + /// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is). + remake: Option<&'a Remake<'a>>, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). @@ -207,6 +231,7 @@ impl<'a> Recolor<'a> { links: None, scope, gray_images: false, + remake: None, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], @@ -229,7 +254,7 @@ impl<'a> Recolor<'a> { /// A pass that maps colors as this one does, with nothing recolored yet. fn again(&self) -> Self { - Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) } + Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) } } /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other @@ -328,7 +353,9 @@ impl<'a> Recolor<'a> { } let f = self.f; let blob = d.images.get(key)?; - let new = if self.gray_images { + let new = if let Some(remake) = self.remake { + remake(blob) + } else if self.gray_images { blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0]) } else { blob.map_rgb(|[r, g, b]| { @@ -534,6 +561,187 @@ impl NotChanged { } } +/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and +/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden. +fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { + Ok(match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } + }) +} + +/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as +/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is +/// recorded when no image changes. +fn to_indexed(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let indexing = Indexing::of(s, p)?; + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let remake = |b: &ImageBlob| indexing.convert(b); + let images = Scope { fill: false, stroke: false, images: true, patterns: false }; + let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; + // A left-out image counts as one the pass would change when it is an image that is not an + // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. + let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); + let (ids, left) = pass_targets(s, p, C)?; + let st = s.doc()?; + let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), + ); + let mut doc = (*st.doc).clone(); + let n = pass.run(&mut doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + if n > 0 { + s.edit("Convert to Indexed Color", |d, _| { + *d = doc; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + })?; + } + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8 +/// export, with transparency kept and no matte, and the fixed palette of the uniform and document +/// reductions. +struct Indexing { + options: PaletteOptions, + fixed: Option>, + /// The size of an image [`Self::convert`] left as it is because it has more than + /// [`Self::MAX_PIXELS`] pixels. + too_large: Cell>, +} + +impl Indexing { + /// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at + /// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the + /// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered + /// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice + /// over. + const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16; + + /// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the + /// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them. + fn of(s: &Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let reduction = text_param(p, "reduction", C)?.unwrap_or("selective"); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16) + } + }; + let dither = match text_param(p, "dither", C)? { + None => Dither::Diffusion, + Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?, + }; + let dither_amount = match p.get("ditherAmount") { + None | Some(Value::Null) => 100, + Some(v) => { + v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8 + } + }; + let options = + |r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None }; + let refuse_colors = |name: &str| match colors { + Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))), + None => Ok(()), + }; + let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) }; + Ok(match reduction.to_ascii_lowercase().as_str() { + "uniform" => { + let n = colors.unwrap_or(256); + // One entry is kept for transparent pixels. + let levels = + (2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?; + let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8; + let mut palette = Vec::with_capacity(usize::from(levels.pow(3))); + for r in 0..levels { + for g in 0..levels { + for b in 0..levels { + palette.push([level(r), level(g), level(b)]); + } + } + } + fixed(palette) + } + "document" => { + refuse_colors("document")?; + let mut palette: Vec<[u8; 3]> = vec![]; + for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) { + let [r, g, b, _] = c.to_rgba8(1.0); + if !palette.contains(&[r, g, b]) { + palette.push([r, g, b]); + } + } + if palette.is_empty() { + return Err(bad(C, "the document has no color swatches")); + } + if palette.len() > 255 { + return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len()))); + } + fixed(palette) + } + id => { + let r = Reduction::from_id(id).ok_or_else(|| { + bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)")) + })?; + if r == Reduction::BlackWhite { + refuse_colors("blackWhite")?; + } + Self { options: options(r), fixed: None, too_large: Cell::new(None) } + } + }) + } + + /// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than + /// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and + /// every pixel would show as it does. + fn convert(&self, b: &ImageBlob) -> Option { + let (w, h) = b.pixel_size()?; + if u64::from(w) * u64::from(h) > Self::MAX_PIXELS { + self.too_large.set(Some((w, h))); + return None; + } + let (w, h, rgba) = b.rgba()?; + let ix = match &self.fixed { + Some(palette) => quantize_to(&rgba, w, h, &self.options, palette), + None => quantize(&rgba, w, h, &self.options), + }; + if b.indexed_png() && shows_same(&ix, &rgba) { + return None; + } + // The PNG writer's buffers take the place of the pixels. + drop(rgba); + Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?)) + } +} + +/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent +/// where they are transparent, and the same color where they are opaque. +fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool { + let px = rgba.as_chunks::<4>().0; + px.len() == ix.indices.len() + && px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) { + (0, clear) => clear, + (255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]), + _ => false, + }) +} + fn saturate(s: &mut Session, p: &Value) -> Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 8071c1df3d..2f6252d938 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -158,6 +158,7 @@ pub(super) const ALLOWED: &[&str] = &[ "edit.colors.saturate", "edit.colors.toCMYK", "edit.colors.toGrayscale", + "edit.colors.toIndexed", "edit.colors.toRGB", "edit.duplicate", "edit.findNext", diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index a3dcea263a..e3aa59368a 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -484,6 +484,250 @@ fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() { assert!(source(false).is_none(), "the colors keep their model"); } +/// Convert to Indexed Color stores an image as a palette PNG in one undo step; an image with few +/// enough colors keeps them, and converting it again changes nothing and adds no undo step. +#[test] +fn convert_to_indexed_stores_an_image_as_a_palette_png() { + let mut s = session(); + let id = image_node(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + let key = image_key(&s, id); + assert!(s.doc().unwrap().doc.images[&key].indexed_png(), "an indexed PNG"); + assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]], "half opaque becomes opaque"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let again = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(again["changed"], 0, "{again}"); + assert_eq!(image_key(&s, id), key); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "no undo step when nothing changes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixels(&s, id)[3], [255, 255, 255, 128], "one undo step restores the image"); +} + +/// Pixels under half opacity become transparent; an indexed image converted to a palette it +/// already shows is left as it is, and one converted to a smaller palette changes. +#[test] +fn convert_to_indexed_keeps_transparency_and_reduces_an_indexed_image() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [0, 0, 0, 100], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id)[2][3], 0, "under half opacity: transparent"); + let r = s.execute("edit.colors.toIndexed", &json!({"reduction": "blackWhite", "dither": "none"})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + let px = pixels(&s, id); + assert_eq!([px[0], px[1], px[3]], [[0, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255]]); + assert_eq!(px[2][3], 0); +} + +/// The uniform reduction takes evenly spaced levels of each channel, and the document reduction +/// the colors of the document's swatches. +#[test] +fn convert_to_indexed_uses_a_uniform_or_the_document_palette() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 9, "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap(); + let swatches: Vec<[u8; 3]> = crate::cmd::swatchlib::library_colors(&s, "document") + .unwrap() + .iter() + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }) + .collect(); + for p in pixels(&s, id) { + assert!(swatches.contains(&[p[0], p[1], p[2]]), "{p:?} is a swatch color"); + } +} + +/// The start of a PNG of `w` × `h` RGBA pixels: its header and a first image data chunk, without +/// the rest of the pixels. +fn png_header(w: u32, h: u32) -> Vec { + let crc = |data: &[u8]| { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + }; + let chunk = |ty: &[u8; 4], data: &[u8]| { + let body = [ty.as_slice(), data].concat(); + [(data.len() as u32).to_be_bytes().as_slice(), &body, &crc(&body).to_be_bytes()].concat() + }; + let ihdr = [w.to_be_bytes().as_slice(), &h.to_be_bytes(), &[8, 6, 0, 0, 0]].concat(); + [b"\x89PNG\r\n\x1a\n".as_slice(), &chunk(b"IHDR", &ihdr), &chunk(b"IDAT", &[0x78, 0x9c, 0x03, 0x00])].concat() +} + +/// An image larger than Convert to Indexed Color converts is refused by its size, read from the +/// file's header, before any pixel is decoded; nothing changes. +#[test] +fn convert_to_indexed_refuses_an_image_too_large_to_convert() { + let mut s = session(); + let id = image_node(&mut s); + assert_eq!(vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)).pixel_size(), Some((30_000, 30_000))); + s.edit("Swap", |d, _| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000))); + Ok(()) + }) + .unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("edit.colors.toIndexed", &json!({})).unwrap_err().to_string(); + assert!(e.contains("an image of 30000 × 30000 pixels is too large to convert"), "{e}"); + assert_eq!(image_key(&s, id), "pic"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +/// Convert to Indexed Color refuses parameters it can't use, and leaves the image as it is. +#[test] +fn convert_to_indexed_refuses_bad_params() { + let mut s = session(); + let id = image_node(&mut s); + for (p, msg) in [ + (json!({"colors": 1}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": 257}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": "8"}), "`colors` must be a whole number from 2 to 256"), + (json!({"colors": 16.5}), "`colors` must be a whole number from 2 to 256"), + (json!({"reduction": "uniform", "colors": 8}), "`colors` must be at least 9 for the uniform reduction"), + (json!({"reduction": "document", "colors": 16}), "`colors` does not apply to the document reduction"), + (json!({"reduction": "blackWhite", "colors": 2}), "`colors` does not apply to the blackWhite reduction"), + (json!({"reduction": "posterize"}), "unknown reduction `posterize`"), + (json!({"dither": "random"}), "unknown dither `random`"), + (json!({"ditherAmount": 101}), "`ditherAmount` must be a number from 0 to 100"), + (json!({"ditherAmount": "50"}), "`ditherAmount` must be a number from 0 to 100"), + ] { + let e = s.execute("edit.colors.toIndexed", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!(image_key(&s, id), "pic"); +} + +/// An embedded `w` × `h` image of the pixels `f(x, y)`, placed and selected. +fn placed(s: &mut Session, w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> NodeId { + let mut png = Vec::new(); + image::RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y))).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + NodeId(r["ids"][0].as_u64().unwrap()) +} + +/// `ids` picks the images to convert; a linked image and a colorized image keep their pixels. +#[test] +fn convert_to_indexed_takes_ids_and_leaves_linked_and_colorized_images() { + let mut s = session(); + let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]); + let b = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]); + let (ka, kb) = (image_key(&s, a), image_key(&s, b)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(image_key(&s, a), ka, "not in `ids`"); + assert_ne!(image_key(&s, b), kb); + s.edit("Link", |d, _| { + let NodeKind::Image(im) = &mut d.node_mut(a).unwrap().kind else { panic!("not an image") }; + im.link = Some(vectorcraft_doc::LinkInfo::new("art.png")); + Ok(()) + }) + .unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(r["changed"], 0, "a linked image: {r}"); + assert_eq!(image_key(&s, a), ka); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let mut png = Vec::new(); + image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let r = s.execute("file.place", &json!({"name": "gray.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let gray = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [gray.0], "color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(gray).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let kg = image_key(&s, gray); + let r = s.execute("edit.colors.toIndexed", &json!({"ids": [gray.0]})).unwrap(); + assert_eq!(r["changed"], 0, "a colorized image: {r}"); + assert_eq!(image_key(&s, gray), kg); +} + +/// With the selection Select All makes, locked and hidden images are left out and counted in +/// `notChanged`; a left-out image too large to convert is counted without an error. +#[test] +fn convert_to_indexed_counts_left_out_images() { + let mut s = session(); + let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]); + let locked = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]); + let hidden = placed(&mut s, 2, 1, |x, _| [120, 140 + x as u8, 20, 255]); + s.edit("Huge", |d, _| { + let NodeKind::Image(im) = &mut d.node_mut(locked).unwrap().kind else { panic!("not an image") }; + im.key = "huge".into(); + d.images.insert("huge".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000))); + Ok(()) + }) + .unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let kh = image_key(&s, hidden); + let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 1}), "{r}"); + assert!(r["warning"].is_string(), "{r}"); + assert!(s.doc().unwrap().doc.images[&image_key(&s, a)].indexed_png()); + assert_eq!((image_key(&s, locked), image_key(&s, hidden)), ("huge".to_string(), kh)); +} + +/// The uniform reduction takes the most levels a channel whose cube leaves one entry for +/// transparency (3 for 64 colors, 6 for 256), and the document reduction takes 1 to 255 swatch +/// colors. +#[test] +fn convert_to_indexed_sizes_the_uniform_and_document_palettes() { + let mut s = session(); + let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 64, "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[255, 0, 127, 255], [0, 0, 255, 255], [127, 127, 127, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "dither": "none"})).unwrap(); + assert_eq!(pixels(&s, id), [[204, 51, 102, 255], [0, 51, 204, 255], [102, 153, 153, 255], [255, 255, 255, 255]]); + s.execute("edit.undo", &json!({})).unwrap(); + s.edit("No swatches", |d, _| { + d.swatches.clear(); + d.swatch_groups.clear(); + Ok(()) + }) + .unwrap(); + let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string(); + assert!(e.contains("the document has no color swatches"), "{e}"); + s.edit("Many swatches", |d, _| { + for i in 0..256u32 { + let paint = Paint::solid(Color::rgb8(i as u8, 7, 9)); + d.swatches.push(vectorcraft_color::swatch::Swatch { name: format!("c{i}"), paint, global: false, spot: false }); + } + Ok(()) + }) + .unwrap(); + let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string(); + assert!(e.contains("the document's swatches hold 256 colors; the document reduction takes at most 255"), "{e}"); + assert_eq!(image_key(&s, id), "pic"); +} + +/// `dither` and `ditherAmount` reach the quantizer: a gray ramp reduced to black and white is +/// dithered by each method, and at an amount of 0 it comes out as with no dither. +#[test] +fn convert_to_indexed_dithers_by_the_amount_given() { + let ramp = |p: Value| { + let mut s = session(); + let id = placed(&mut s, 16, 4, |x, _| [(x * 17) as u8, (x * 17) as u8, (x * 17) as u8, 255]); + s.execute("edit.colors.toIndexed", &p).unwrap(); + pixels(&s, id) + }; + let none = ramp(json!({"reduction": "blackWhite", "dither": "none"})); + for d in ["diffusion", "pattern", "noise"] { + assert_eq!(ramp(json!({"reduction": "blackWhite", "dither": d, "ditherAmount": 0})), none, "{d} at 0"); + assert_ne!(ramp(json!({"reduction": "blackWhite", "dither": d})), none, "{d} at 100"); + } +} + /// A PDF of a CMYK document writes an image Convert to Grayscale made with one channel (#5632). #[test] fn a_converted_image_is_written_to_pdf_as_gray_in_a_cmyk_document() { diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 24a1635c30..b274e7956f 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -368,6 +368,7 @@ fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unse ("edit.colors.invert", "ids", json!({}), Selected), ("edit.colors.toCMYK", "ids", json!({}), Selected), ("edit.colors.toGrayscale", "ids", json!({}), Selected), + ("edit.colors.toIndexed", "ids", json!({}), Selected), ("edit.colors.toRGB", "ids", json!({}), Selected), ("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected), ("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 3e80e0a2dd..a72a695b5d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -945,6 +945,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", ), + ( + "ui.indexedColorDialog", + "Convert to Indexed Color…", + "", + "{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step", + ), ( "window.swatchLibrary", "Swatch Library", diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index ec04c9f466..0c873fd598 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -154,18 +154,8 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed { let px = rgba.as_chunks::<4>().0; let clear = |p: &[u8; 4]| o.transparency && p[3] < 128; - let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite); let matte = o.matte.unwrap_or([255; 3]); - let blend = o.matte.is_some() || !o.transparency; - // The opaque colours: blended over the matte, greys for the grey reductions. - let colors: Vec<[u8; 3]> = px - .iter() - .map(|p| { - let a = if blend { p[3] as u32 } else { 255 }; - let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8); - if gray { [luma(c); 3] } else { c } - }) - .collect(); + let colors = opaque_colors(px, o, matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite)); let has_clear = px.iter().any(clear); let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1); let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c); @@ -195,11 +185,46 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, if palette.is_empty() { palette.push(matte); } + finish(px, &colors, width, height, &palette, o, if exact { Dither::None } else { o.dither }) +} + +/// [`quantize`] to the fixed `palette`: each pixel takes its nearest entry, with `o`'s dither, +/// transparency and matte; `o.reduction` sets only the distance weights, and `o.colors` is not +/// used. Only the first 255 colors of `palette` are used, leaving room for the transparent entry. +/// Entries no pixel uses are left out. +pub fn quantize_to(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, palette: &[[u8; 3]]) -> Indexed { + let px = rgba.as_chunks::<4>().0; + let colors = opaque_colors(px, o, false); + let mut palette: Vec<[u8; 3]> = palette.iter().take(255).copied().collect(); + if palette.is_empty() { + palette.push(o.matte.unwrap_or([255; 3])); + } + finish(px, &colors, width, height, &palette, o, o.dither) +} + +/// The color of each pixel when opaque: blended over the matte, and gray when `gray`. +fn opaque_colors(px: &[[u8; 4]], o: &PaletteOptions, gray: bool) -> Vec<[u8; 3]> { + let matte = o.matte.unwrap_or([255; 3]); + let blend = o.matte.is_some() || !o.transparency; + px.iter() + .map(|p| { + let a = if blend { p[3] as u32 } else { 255 }; + let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8); + if gray { [luma(c); 3] } else { c } + }) + .collect() +} + +/// Map the pixels `px` (their opaque `colors`) to `palette` with `dither`, then drop the entries +/// no pixel uses and put the transparent entry first when a pixel is transparent. +fn finish(px: &[[u8; 4]], colors: &[[u8; 3]], width: u32, height: u32, palette: &[[u8; 3]], o: &PaletteOptions, dither: Dither) -> Indexed { + let clear = |p: &[u8; 4]| o.transparency && p[3] < 128; + let matte = o.matte.unwrap_or([255; 3]); + let has_clear = px.iter().any(clear); let weights = o.reduction.weights(); - let dither = if exact { Dither::None } else { o.dither }; // (A width past the pixels there are would size the error rows from a number nothing checked.) let mut indices = - map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); + map(colors, (width as usize).min(colors.len()), palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); // Drop the entries no pixel uses (fixed palettes), keeping the order. let mut used = [false; 256]; for (i, &ix) in indices.iter().enumerate() { diff --git a/crates/render/src/encode/tests_palette.rs b/crates/render/src/encode/tests_palette.rs index 53a139f3e1..f06217f397 100644 --- a/crates/render/src/encode/tests_palette.rs +++ b/crates/render/src/encode/tests_palette.rs @@ -3,7 +3,7 @@ use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; use super::png::{PngOptions, encode_indexed}; -use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize}; +use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::*; /// A `w`×`h` opaque image of `f(x, y)` colours. @@ -93,6 +93,22 @@ fn fixed_palettes() { assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]); } +#[test] +fn a_given_palette_keeps_the_entries_pixels_use() { + let px: Vec = [[250, 10, 10, 255], [10, 10, 240, 255], [0, 0, 0, 40], [240, 20, 30, 255]].concat(); + let given = [[0, 255, 0], [255, 0, 0], [0, 0, 255], [255, 255, 255]]; + let o = PaletteOptions { dither: Dither::None, matte: None, ..Default::default() }; + let ix = quantize_to(&px, 4, 1, &o, &given); + assert_eq!(ix.transparent, Some(0)); + assert_eq!(ix.palette[1..], [[255, 0, 0], [0, 0, 255]], "unused entries left out, the order kept"); + assert_eq!(ix.indices, [1, 2, 0, 1]); + // At most 255 of the given colors are used, leaving room for the transparent entry. + let many: Vec<[u8; 3]> = (0..300u32).map(|i| [(i % 256) as u8, (i / 256) as u8, 0]).collect(); + let all = image(256, 2, |x, y| [x as u8, y as u8, 0]); + let ix = quantize_to(&all, 256, 2, &o, &many); + assert!(ix.palette.len() <= 255 && ix.palette.iter().all(|c| many[..255].contains(c)), "{}", ix.palette.len()); +} + #[test] fn dithering_keeps_the_average_colour() { let px = image(64, 16, |x, _| [(x * 4) as u8; 3]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6040ac8fb4..1406d25530 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -60,6 +60,7 @@ pub mod graphic_style_options; pub mod halftone; pub mod hyphenation; pub mod import_pdf; +pub mod indexed_color; pub mod javascript_files; pub mod justification; pub mod layer_options; @@ -347,6 +348,7 @@ registry! { ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, ColorBalance: [color_balance::KIND] => color_balance::SPEC, Saturate: [saturate::KIND] => saturate::SPEC, + IndexedColor: [indexed_color::KIND] => indexed_color::SPEC, SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC, EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC, diff --git a/crates/ui-egui/src/dialogs/indexed_color.rs b/crates/ui-egui/src/dialogs/indexed_color.rs new file mode 100644 index 0000000000..6ef266c06c --- /dev/null +++ b/crates/ui-egui/src/dialogs/indexed_color.rs @@ -0,0 +1,135 @@ +//! Edit → Edit Colors → Convert to Indexed Color: the palette (reduction and colors) and the +//! dither of the selection's embedded images, previewed live on the canvas; OK keeps the preview +//! as one undo step (`edit.colors.toIndexed`). +//! +//! Fields: `reduction`, `colors`, `dither`, `ditherAmount` and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_render::encode::quantize::{Dither, Reduction}; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Convert to Indexed Color. +pub const KIND: &str = "indexedColor"; + +const CMD: &str = "edit.colors.toIndexed"; + +/// The reductions besides those of a PNG-8 export, as (value, label). +const MORE_REDUCTIONS: [(&str, &str); 2] = [("uniform", "Uniform"), ("document", "Document Swatches")]; + +/// Label column and dropdown widths. +const WIDTHS: (f32, f32) = (110.0, 160.0); + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Convert to Indexed Color").into(), body, confirm, preview: true, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the defaults of `edit.colors.toIndexed`. +pub fn open(app: &mut VectorcraftApp) { + let fields = json!({"reduction": "selective", "colors": 256, "dither": "diffusion", "ditherAmount": 100, "preview": true}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// Whether reduction `r` takes a number of colors. +fn takes_colors(r: &str) -> bool { + !matches!(r, "blackWhite" | "document") +} + +/// The fewest colors reduction `r` takes. +fn least_colors(r: &str) -> f64 { + if r == "uniform" { 9.0 } else { 2.0 } +} + +/// `edit.colors.toIndexed` parameters: the reduction, its colors when it takes them, and the dither. +fn params(d: &Dialog) -> Value { + let reduction = d.str("reduction"); + let dither = d.str("dither"); + let mut p = json!({"reduction": reduction, "dither": dither}); + if takes_colors(&reduction) { + p["colors"] = json!(d.f64("colors", 256.0).round().clamp(least_colors(&reduction), 256.0) as u16); + } + if dither != Dither::None.id() { + p["ditherAmount"] = json!(d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0)); + } + p +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let reductions: Vec<(&str, &str)> = Reduction::ALL.iter().map(|r| (r.id(), r.label())).chain(MORE_REDUCTIONS).collect(); + form::choice(ui, d, "reduction", tl!("Reduction:"), WIDTHS, &reductions); + let reduction = d.str("reduction"); + if takes_colors(&reduction) { + // The field shows the number the command gets: at least the reduction's fewest. + let least = least_colors(&reduction); + let n = d.f64("colors", 256.0).round().clamp(least, 256.0); + d.fields.insert("colors".into(), json!(n as u16)); + widgets::label_row(ui, tl!("Colors:"), WIDTHS.0, |ui| { + if let Some(n) = widgets::range_field(ui, "indexed-colors", n, least..=256.0, "", 0, 60.0) { + d.fields.insert("colors".into(), json!(n.round() as u16)); + } + }); + } + let dithers: Vec<(&str, &str)> = Dither::ALL.iter().map(|x| (x.id(), x.label())).collect(); + form::choice(ui, d, "dither", tl!("Dither:"), WIDTHS, &dithers); + if d.str("dither") != Dither::None.id() { + widgets::label_row(ui, tl!("Amount:"), WIDTHS.0, |ui| { + if let Some(n) = widgets::range_field(ui, "indexed-dither-amount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 60.0) { + d.fields.insert("ditherAmount".into(), json!(n.round())); + } + }); + } + let p = params(d); + form::preview(app, ui, d, "Convert to Indexed Color", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn commits_the_chosen_palette_as_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let mut png = Vec::new(); + let img = image::RgbaImage::from_raw(2, 1, vec![200, 30, 90, 255, 20, 40, 220, 255]).unwrap(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = app + .session + .edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.run("ui.indexedColorDialog", json!({})).unwrap(); + let fields = &mut app.ui.dialog.as_mut().unwrap().fields; + fields.insert("reduction".into(), json!("blackWhite")); + fields.insert("dither".into(), json!("none")); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let vectorcraft_doc::NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("not an image") }; + let blob = &st.doc.images[&im.key]; + assert!(blob.indexed_png()); + assert_eq!(blob.rgba().unwrap().2, [0, 0, 0, 255, 0, 0, 0, 255], "black and white only"); + } +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a7d62b6daf..55cf45ce2f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -428,6 +428,7 @@ fn dialogs_do_not_stretch_to_the_screen() { ("ui.colorGuideOptions", json!({})), ("ui.colorBalanceDialog", json!({})), ("ui.saturateDialog", json!({})), + ("ui.indexedColorDialog", json!({})), ("ui.saveSwatchLibrary", json!({})), ("ui.flattenTransparencyDialog", json!({})), ("ui.expandDialog", json!({})), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 708ffe9db6..5b53c33df9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -614,6 +614,8 @@ font-technology Variable Променливи Convert to Artboards Конвертиране в работни области Convert to CMYK Конвертиране в CMYK Convert to Grayscale Конвертиране в ска̀ла на сивото + Convert to Indexed Color Конвертиране в индексирани цветове + Convert to Indexed Color… Конвертиране в индексирани цветове… Convert To Outlines Конвертиране в контури Convert To Point Type Конвертиране в точков текст Convert to RGB Конвертиране в RGB diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f5ea73487..af6a4ea010 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -103,6 +103,8 @@ Blend Vertically Stopi okomito Convert to CMYK Pretvori u CMYK Convert to Grayscale Pretvori u nijanse sive + Convert to Indexed Color Pretvori u indeksirane boje + Convert to Indexed Color… Pretvori u indeksirane boje… Convert to RGB Pretvori u RGB Invert Colors Obrni boje Overprint Black… Pretisak crne… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5516c9662f..0eb8489e62 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -906,6 +906,8 @@ antiAlias Sharp Nítid Convert to destination (preserve numbers) Converteix al destí (conserva els números) Convert to Global Converteix en global Convert to Grayscale Converteix a escala de grisos + Convert to Indexed Color Converteix a color indexat + Convert to Indexed Color… Converteix a color indexat… Convert To Outlines Converteix en contorns Convert To Point Type Converteix en text de punt Convert to RGB Converteix a RGB diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d03d192a3a..9a9e40d6e6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -108,6 +108,8 @@ Blend Vertically Prolnout svisle Convert to CMYK Převést na CMYK Convert to Grayscale Převést na stupně šedi + Convert to Indexed Color Převést na indexované barvy + Convert to Indexed Color… Převést na indexované barvy… Convert to RGB Převést na RGB Invert Colors Invertovat barvy Overprint Black… Přetisknout černou… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00c85aaeb6..362df38e9b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -96,6 +96,8 @@ Blend Vertically Bland lodret Convert to CMYK Konvertér til CMYK Convert to Grayscale Konvertér til gråtoner + Convert to Indexed Color Konvertér til indekserede farver + Convert to Indexed Color… Konvertér til indekserede farver… Convert to RGB Konvertér til RGB Invert Colors Invertér farver Overprint Black… Overprint sort… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 47337855aa..22913bdc2d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -607,6 +607,8 @@ font-technology Variable Variabel Convert to Artboards In Zeichenflächen umwandeln Convert to CMYK In CMYK konvertieren Convert to Grayscale In Graustufen konvertieren + Convert to Indexed Color In indizierte Farben konvertieren + Convert to Indexed Color… In indizierte Farben konvertieren… Convert To Outlines In Pfade umwandeln Convert To Point Type In Punkttext umwandeln Convert to RGB In RGB konvertieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c319729a71..9be6950447 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -588,6 +588,8 @@ font-technology Variable Μεταβλητές Convert to Artboards Μετατροπή σε πίνακες σχεδίασης Convert to CMYK Μετατροπή σε CMYK Convert to Grayscale Μετατροπή σε κλίμακα του γκρι + Convert to Indexed Color Μετατροπή σε χρώματα με ευρετήριο + Convert to Indexed Color… Μετατροπή σε χρώματα με ευρετήριο… Convert To Outlines Μετατροπή σε περιγράμματα Convert To Point Type Μετατροπή σε κείμενο σημείου Convert to RGB Μετατροπή σε RGB diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b203292a4..a408ab1edf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -629,6 +629,8 @@ font-technology Variable Variables Convert to Artboards Convertir en mesas de trabajo Convert to CMYK Convertir a CMYK Convert to Grayscale Convertir a escala de grises + Convert to Indexed Color Convertir a color indexado + Convert to Indexed Color… Convertir a color indexado… Convert To Outlines Convertir en contornos Convert To Point Type Convertir en texto de punto Convert to RGB Convertir a RGB diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b2c6f48d54..8947e5dcc9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -116,6 +116,8 @@ Blend Vertically Sulauta pystysuunnassa Convert to CMYK Muunna CMYK:ksi Convert to Grayscale Muunna harmaasävyiseksi + Convert to Indexed Color Muunna indeksoiduiksi väreiksi + Convert to Indexed Color… Muunna indeksoiduiksi väreiksi… Convert to RGB Muunna RGB:ksi Invert Colors Käännä värit Overprint Black… Päällepainata musta… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e84f344060..156bc45cec 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -630,6 +630,8 @@ font-technology Variable Variables Convert to Artboards Convertir en plans de travail Convert to CMYK Convertir en CMJN Convert to Grayscale Convertir en niveaux de gris + Convert to Indexed Color Convertir en couleurs indexées + Convert to Indexed Color… Convertir en couleurs indexées… Convert To Outlines Vectoriser Convert To Point Type Convertir en texte de point Convert to RGB Convertir en RVB diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..e442f7b320 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1020,6 +1020,8 @@ antiAlias Sharp शार्प Convert to destination (preserve numbers) गंतव्य में कनवर्ट करें (संख्या संरक्षित करें) Convert to Global ग्लोबल में कनवर्ट करें Convert to Grayscale ग्रेस्केल में बदलें + Convert to Indexed Color अनुक्रमित रंग में बदलें + Convert to Indexed Color… अनुक्रमित रंग में बदलें… Convert To Outlines कन्वर्ट करने के लिए रूपरेखा Convert to Parametric Shape पैरामीट्रिक शेप में कन्वर्ट करें Convert To Point Type पॉइंट टाइप में कनवर्ट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index effb31e5d8..c8d1262fa4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -820,6 +820,8 @@ font-technology Variable Változó Convert to destination (preserve numbers) Átalakítás a célprofilra (az értékek megőrzésével) Convert to Global Átalakítás globálissá Convert to Grayscale Átalakítás szürkeárnyalatossá + Convert to Indexed Color Átalakítás indexelt színűvé + Convert to Indexed Color… Átalakítás indexelt színűvé… Convert To Outlines Átalakítás útvonallá Convert To Point Type Átalakítás pontszöveggé Convert to RGB Átalakítás RGB-re diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 912c6954eb..764c16f784 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -96,6 +96,8 @@ Blend Vertically Blend vertikal Convert to CMYK Konversi ke CMYK Convert to Grayscale Konversi ke skala kelabu + Convert to Indexed Color Konversi ke warna terindeks + Convert to Indexed Color… Konversi ke warna terindeks… Convert to RGB Konversi ke RGB Invert Colors Balikkan warna Overprint Black… Overprint hitam… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d6c7e2186..d5269965f2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -629,6 +629,8 @@ font-technology Variable Variabili Convert to Artboards Converti in tavole da disegno Convert to CMYK Converti in CMYK Convert to Grayscale Converti in scala di grigi + Convert to Indexed Color Converti in colore indicizzato + Convert to Indexed Color… Converti in colore indicizzato… Convert To Outlines Converti in contorni Convert To Point Type Converti in testo per punti Convert to RGB Converti in RGB diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17444d894e..b7ac4f0cfd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -182,6 +182,8 @@ digits Malayalam マラヤーラム数字 Blend Vertically 上下にブレンド Convert to CMYK CMYK に変換 Convert to Grayscale グレースケールに変換 + Convert to Indexed Color インデックスカラーに変換 + Convert to Indexed Color… インデックスカラーに変換… Convert to RGB RGB に変換 Invert Colors カラー反転 Overprint Black… オーバープリントブラック… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be8a847cb2..60cf44518e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -107,6 +107,8 @@ Blend Vertically ვერტიკალურად შერევა Convert to CMYK CMYK-ად გარდაქმნა Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა + Convert to Indexed Color ინდექსირებულ ფერებად გარდაქმნა + Convert to Indexed Color… ინდექსირებულ ფერებად გარდაქმნა… Convert to RGB RGB-ად გარდაქმნა Invert Colors ფერების ინვერსია Overprint Black… შავის ზედდაბეჭდვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991daba6d0..2d91559506 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -175,6 +175,8 @@ digits Malayalam 말라얄람어 Blend Vertically 세로 혼합 Convert to CMYK CMYK로 변환 Convert to Grayscale 회색조로 변환 + Convert to Indexed Color 인덱스 색상으로 변환 + Convert to Indexed Color… 인덱스 색상으로 변환… Convert to RGB RGB로 변환 Invert Colors 색상 반전 Overprint Black… 검정 겹쳐찍기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b8ca33858f..b82f07dfd5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -597,6 +597,8 @@ Blend Vertically Verticaal mengen Convert to CMYK Converteren naar CMYK Convert to Grayscale Converteren naar grijswaarden + Convert to Indexed Color Converteren naar geïndexeerde kleuren + Convert to Indexed Color… Converteren naar geïndexeerde kleuren… Convert to RGB Converteren naar RGB Invert Colors Kleuren omkeren Overprint Black… Zwart overprinten… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eb5afe87cc..65dfc7793b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -111,6 +111,8 @@ Blend Vertically Przejście w pionie Convert to CMYK Konwertuj na CMYK Convert to Grayscale Konwertuj na skalę szarości + Convert to Indexed Color Konwertuj na kolory indeksowane + Convert to Indexed Color… Konwertuj na kolory indeksowane… Convert to RGB Konwertuj na RGB Invert Colors Odwróć kolory Overprint Black… Nadruk czerni… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80615d6370..67f6b9a4ec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -573,6 +573,8 @@ antiAlias Sharp Nítido Convert to Artboards Converter em Mesas de Trabalho Convert to CMYK Converter para CMYK Convert to Grayscale Converter para Escala de Cinza + Convert to Indexed Color Converter para Cores Indexadas + Convert to Indexed Color… Converter para Cores Indexadas… Convert to RGB Converter para RGB Convert to Shape Converter em Forma Convert to sRGB Converter para sRGB diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c830e69b37..db6ae094ea 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -627,6 +627,8 @@ font-technology Variable Вариативные Convert to Artboards Преобразовать в монтажные области Convert to CMYK Преобразовать в CMYK Convert to Grayscale Преобразовать в оттенки серого + Convert to Indexed Color Преобразовать в индексированные цвета + Convert to Indexed Color… Преобразовать в индексированные цвета… Convert To Outlines Преобразовать в контуры Convert To Point Type Преобразовать в точечный текст Convert to RGB Преобразовать в RGB diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d4304b97bd..63d0c87e80 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -616,6 +616,8 @@ font-technology Variable Variabla Convert to Artboards Konvertera till ritytor Convert to CMYK Konvertera till CMYK Convert to Grayscale Konvertera till gråskala + Convert to Indexed Color Konvertera till indexerade färger + Convert to Indexed Color… Konvertera till indexerade färger… Convert To Outlines Konvertera till konturbanor Convert To Point Type Konvertera till punkttext Convert to RGB Konvertera till RGB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5fc694ec2..a61d5e4ff2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -608,6 +608,8 @@ font-technology Variable Değişken Convert to Artboards Çalışma Yüzeylerine Dönüştür Convert to CMYK CMYK'ye dönüştür Convert to Grayscale Gri Tonlamaya Dönüştür + Convert to Indexed Color Dizinlenmiş Renge Dönüştür + Convert to Indexed Color… Dizinlenmiş Renge Dönüştür… Convert To Outlines Anahatlara Dönüştür Convert To Point Type Nokta Türüne Dönüştür Convert to RGB RGB'ye dönüştür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a36512db0..8ded987bc7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -623,6 +623,8 @@ font-technology Variable Варіативні Convert to Artboards Перетворити на монтажні області Convert to CMYK Перетворити на CMYK Convert to Grayscale Перетворити на відтінки сірого + Convert to Indexed Color Перетворити на індексовані кольори + Convert to Indexed Color… Перетворити на індексовані кольори… Convert To Outlines Перетворити на контури Convert To Point Type Перетворити на точковий текст Convert to RGB Перетворити на RGB diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 839f5c7405..3e010914da 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -721,6 +721,8 @@ font-technology Variable Biến thiên Convert to Artboards Chuyển thành bảng vẽ Convert to CMYK Chuyển sang CMYK Convert to Grayscale Chuyển sang thang xám + Convert to Indexed Color Chuyển sang màu chỉ mục + Convert to Indexed Color… Chuyển sang màu chỉ mục… Convert To Outlines Chuyển thành đường bao Convert To Point Type Chuyển sang chữ điểm Convert to RGB Chuyển sang RGB diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b0d6001e32..a98a0fed1a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -575,6 +575,8 @@ antiAlias Sharp 锐利 Convert to Artboards 转换为画板 Convert to CMYK 转换为 CMYK Convert to Grayscale 转换为灰度 + Convert to Indexed Color 转换为索引颜色 + Convert to Indexed Color… 转换为索引颜色… Convert To Outlines 转换为轮廓 Convert To Point Type 转换为点文字 Convert to RGB 转换为 RGB diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c24ae37609..5a7cdd9f96 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -591,6 +591,8 @@ font-technology Variable 可變 Convert to Artboards 轉換為工作區域 Convert to CMYK 轉換為 CMYK Convert to Grayscale 轉換為灰階 + Convert to Indexed Color 轉換為索引色彩 + Convert to Indexed Color… 轉換為索引色彩… Convert To Outlines 轉換為外框 Convert To Point Type 轉換為點狀文字 Convert to RGB 轉換為 RGB diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..2e9e6e0890 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -814,6 +814,10 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + crate::dialogs::indexed_color::open(app); + Ok(Value::Null) + } "window.swatchLibrary" => crate::panels::swatches::open_library(app, p), "window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")), "ui.saveSwatchLibrary" => { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 4e4da87380..50885f4869 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -286,7 +286,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.newBrush" => app.session.active().is_some(), "ui.brushOptions" => crate::dialogs::brush_options::available(app), "ui.hyphenation" | "ui.justification" | "ui.tateChuYokoSettings" => crate::panels::character::text_style(app).is_some(), - "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.colorBalanceDialog" | "ui.saturateDialog" | "ui.indexedColorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.saveSwatchLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(), "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 5b88346f82..4b86b4152d 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -121,6 +121,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Blend Vertically", "edit.colors.blendVertically"), c("Convert to CMYK", "edit.colors.toCMYK"), c("Convert to Grayscale", "edit.colors.toGrayscale"), + c("Convert to Indexed Color…", "ui.indexedColorDialog"), c("Convert to RGB", "edit.colors.toRGB"), c("Invert Colors", "edit.colors.invert"), cp( diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 31340a3a48..af6da131e0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -946,6 +946,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", ), + ( + "ui.indexedColorDialog", + "Convert to Indexed Color…", + "", + "{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step", + ), ( "window.swatchLibrary", "Swatch Library", diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..7a21d9d098 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3763,11 +3763,32 @@ with one gray channel, so a PDF writes it in gray. `edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest end). Without one, or with `null`, it answers `` missing number `intensity` ``; with a value that is not a number, such as `"40"`, it answers `` `intensity` must be a number, not "40" ``. Either way it changes nothing. -The `edit.colors.*` commands, `edit.colors.overprintBlack` and `addon.color.filter` answer a `fill`, `stroke`, -`includeImages`, `includePatterns`, `convert`, `remove`, `includeCmyBlacks` or `includeSpotBlacks` that is not true or -false with `` `fill` must be true or false ``, naming the key, a `percentage` that is not a number with -`` `percentage` must be a number ``, and a `mode` that is not a string with `` `mode` must be a string ``. They change -nothing and record no undo step. A null counts as a key left out. +The `edit.colors.*` commands other than `edit.colors.toIndexed`, `edit.colors.overprintBlack` and +`addon.color.filter` answer a `fill`, `stroke`, `includeImages`, `includePatterns`, `convert`, `remove`, +`includeCmyBlacks` or `includeSpotBlacks` that is not true or false with `` `fill` must be true or false ``, naming the +key, a `percentage` that is not a number with `` `percentage` must be a number ``, and a `mode` that is not a string +with `` `mode` must be a string ``. They change nothing and record no undo step. A null counts as a key left out. + +`edit.colors.toIndexed` (Edit › Edit Colors › Convert to Indexed Color…) stores each embedded image in the selection (or +`ids`), in groups too, as an indexed-color PNG: a palette of at most 256 RGB colors, with each pixel the index of one. +`reduction` chooses the palette: `selective` (the default), `perceptual`, `adaptive`, `web`, `gray` and `blackWhite` +choose it as a PNG-8 export does; `uniform` takes evenly spaced levels of red, green and blue (2 to 6 levels, the most +whose cube fits in `colors` with one entry left for transparency); `document` takes the colors of the document's +swatches (at most 255). `colors` (2 to 256, default 256) is the most palette entries, the transparent one included; +`uniform` needs at least 9, and `blackWhite` and `document` refuse it. `dither` is `diffusion` (the default), `pattern`, +`noise` or `none`, and `ditherAmount` is 0 to 100 (default 100). Reduction and dither ids are read as a PNG-8 export +reads them, ignoring case. Pixels under half opacity become one transparent entry and the others become opaque in their +own color. Linked images, colorized image fills and the images of color glyphs in outlined type keep their pixels. Each +image that changes becomes a new image, in one undo step for all of them. An image that is already an indexed PNG and +would show the same pixels is left as it is, and no undo step is recorded when no image changes. `notChanged` counts the +locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet. An image of more than +134,217,728 pixels (33,554,432 in the web app) is refused with an error, read from the file's header before any pixel is +decoded, and nothing changes. A value out of range or of the wrong type, such as `"colors": "8"`, is refused with an +error and changes nothing. + +```json +{"name":"run_command","arguments":{"command":"edit.colors.toIndexed","params":{"reduction":"adaptive","colors":32,"dither":"pattern"}}} +``` The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`. When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..21ca62cff0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1425,6 +1425,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged. - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. A value that is not a number, such as `"40"`, is refused with `` `intensity` must be a number, not "40" ``, as in every command that needs a number (`tests::params::a_malformed_required_number_is_not_reported_as_missing`). `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. + - **Convert to Indexed Color:** Edit › Edit Colors › Convert to Indexed Color… (`edit.colors.toIndexed`) stores the embedded images in the selection as indexed-color PNGs of at most 256 colors. The palette is chosen as for a PNG-8 export, as evenly spaced levels of red, green and blue, or from the document's swatches, with diffusion, pattern or noise dithering; pixels under half opacity become transparent. The dialog previews live, and OK is one undo step. This is VectorCraft's own command and counts toward no score. `tests::editcolors` and `encode::tests_palette` cover it. Scores and estimates unchanged. - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. From 78a06865d7dec3211a8df2b6a73b3ed59c65196a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:30:49 -0800 Subject: [PATCH 2/3] =?UTF-8?q?i18n:=20Hindi=20uses=20one=20word=20for=20a?= =?UTF-8?q?=20variable=20and=20=E0=A4=9C=E0=A5=8B=E0=A4=A1=E0=A4=BC?= =?UTF-8?q?=E0=A5=87=E0=A4=82=20for=20joining=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hindi catalog called a variable चर, परिवर्तनीय, वैरिएबल or वेरिएबल from one row to the next: the Variables panel, Load and Save Variable Library, Variable Options and the data-merge messages mixed them, and so did variable widths, the font technology label and environment variables. Every such row now says वेरिएबल (plural वेरिएबल्स). Close or Join, Join Point Text, Join the lines of each paragraph and most of the path join messages said शामिल हों, which means joining a group; the other join messages used जुड़ना, जॉइन or the English word. They now say जोड़ें, as the Join command already did. The Discord rows keep शामिल हों, and the stroke corner names and the Join tool keep जॉइन. hindi_rows_use_one_term_for_a_variable_and_for_joining_paths checks both terms in every row. docs/localization-hi.md lists the terms, and the Hindi row counts in it and in docs/localization-parity.md are current (8,782 rows, 3,014 @msg). --- crates/ui-egui/src/i18n/hi.tsv | 134 +++++++++++++++---------------- crates/ui-egui/src/i18n/tests.rs | 30 +++++++ docs/localization-hi.md | 12 ++- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 110 insertions(+), 69 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b9401b2b77..40aec2daff 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -850,7 +850,7 @@ antiAlias Sharp शार्प Close All सभी बंद करें Close Gaps With Paths पाथ के साथ अंतराल बंद करें Close Library लाइब्रेरी - Close or Join बंद करें या शामिल हों + Close or Join बंद करें या जोड़ें Close Panel पैनल बंद करें Close Path बंद पाथ Close Path: बंद पाथ: @@ -1216,7 +1216,7 @@ antiAlias Sharp शार्प Default Position डिफ़ॉल्ट स्थिति Default Printer डिफ़ॉल्ट प्रिंटर Default Swatches डिफ़ॉल्ट स्वॉचेस - Define a variable first पहले एक चर को परिभाषित करें + Define a variable first पहले एक वेरिएबल परिभाषित करें Define Grid… ग्रिड परिभाषित… Define Perspective Grid पर्सपेक्टिव ग्रिड डिफ़ाइन करें Define Perspective Grid… पर्सपेक्टिव ग्रिड डिफ़ाइन करें… @@ -1292,7 +1292,7 @@ antiAlias Sharp शार्प Delete this saved preset इस सेव किए गए प्रीसेट को हटा दें Delete Toolbar टूलबार हटाएं Delete Transparency Flattener Preset पारदर्शिता फ़्लैटनर प्रीसेट हटाएँ - Delete Variable चर हटाएँ + Delete Variable वेरिएबल हटाएँ Delete Variables वेरिएबल्स हटाएँ Delete View व्यू हटाएँ Delete Width Point चौड़ाई बिंदु हटाएँ @@ -2235,7 +2235,7 @@ antiAlias Sharp शार्प Highlight Substituted Fonts हाईलाइट सबस्टिट्यूटेड फ़ॉन्ट Highlight Substituted Glyphs बदले हुए ग्लिफ़ हाइलाइट करें Highlight Tracking हाइलाइट ट्रैकिंग - Highlight Variable उच्च प्रकाश परिवर्तनीय + Highlight Variable वेरिएबल हाइलाइट करें Highlight: हाइलाइट: Hindi हिन्दी History हिस्ट्री @@ -2424,9 +2424,9 @@ antiAlias Sharp शार्प Join जोड़ें Join our Discord हमारे डिसकॉर्ड में शामिल हों Join Our Discord हमारे डिसकॉर्ड में शामिल हों - Join Point Text पॉइंट टेक्स्ट में शामिल हों + Join Point Text पॉइंट टेक्स्ट जोड़ें Join the ArtCraft community on Discord (discord.gg/artcraft) Discord (discord.gg/artcraft) पर ArtCraft समुदाय में शामिल हों - Join the lines of each paragraph प्रत्येक पैराग्राफ की लाइनों में शामिल हों + Join the lines of each paragraph हर पैराग्राफ़ की लाइनें जोड़ें Join Tool जॉइन टूल Joins जुड़ना Joins: जुड़ना: @@ -2628,7 +2628,7 @@ antiAlias Sharp शार्प List Preferences सूची प्राथमिकताएं List Slices सूची स्लाइस List Toolbars सूची टूलबार - List Variables सूची चर + List Variables वेरिएबल्स सूचीबद्ध करें List View सूची दृश्य List Workspaces सूची वर्कस्पेस Live curve fitting लाइव वक्र फिटिंग @@ -2651,8 +2651,8 @@ antiAlias Sharp शार्प Load SVG into Clipboard SVG को क्लिपबोर्ड में लोड करें Load Text into Clipboard क्लिपबोर्ड में टेक्स्ट लोड करें Load Texture… लोड बनावट… - Load Variable Library लोड परिवर्तनीय लाइब्रेरी - Load Variable Library… लोड परिवर्तनीय लाइब्रेरी… + Load Variable Library वेरिएबल लाइब्रेरी लोड करें + Load Variable Library… वेरिएबल लाइब्रेरी लोड करें… Loaded Texture भारित बनावट Loads native code मूल कोड लोड करता है Local Libraries स्थानीय लाइब्रेरी @@ -2674,7 +2674,7 @@ antiAlias Sharp शार्प Lock Selected लॉक सिलेक्टेड Lock Slices स्लाइस लॉक करें Lock Station Point लॉक स्टेशन पॉइंट - Lock Variables लॉक चर + Lock Variables वेरिएबल्स लॉक करें Locked लॉक्ड Logo 1000×1000 लोगो 1000 × 1000 Long लंबा @@ -2991,8 +2991,8 @@ antiAlias Sharp शार्प New theme from the current color वर्तमान रंग से नया विषय New Toolbar नया टूलबार New Toolbar… नया टूलबार… - New Variable नया परिवर्तनीय - New Variable… नई परिवर्तनीय… + New Variable नया वेरिएबल + New Variable… नया वेरिएबल… New View नया दृश्य New View… नया दृश्य… New Window नई विंडो @@ -3057,7 +3057,7 @@ antiAlias Sharp शार्प No text selected कोई टेक्स्ट नहीं चुना गया No Thumbnails कोई थंबनेल नहीं No unused symbols अप्रयुक्त सिंबल - no variable to rename नाम बदलने के लिए कोई चर नहीं + no variable to rename नाम बदलने के लिए कोई वेरिएबल नहीं Noise नॉइज़ Nonbreaking Hyphen न टूटने वाला हाइफ़न Nonbreaking Space न टूटने वाला स्पेस @@ -3715,7 +3715,7 @@ antiAlias Sharp शार्प Rays रे Read Action Set File एक्शन सेट फ़ाइल पढ़ें Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें - Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है + Reads or sets environment variables एनवायरनमेंट वेरिएबल्स पढ़ता है या सेट करता है Ready तैयार Real name वास्तविक नाम Real World: रियल वर्ल्ड: @@ -3890,7 +3890,7 @@ antiAlias Sharp शार्प Rename Preset प्रीसेट का नाम बदलें Rename Style स्टाइल का नाम बदलें Rename Toolbar टूलबार नाम दें - Rename Variable चर का नाम बदलें + Rename Variable वेरिएबल का नाम बदलें Rename View व्यू का नाम बदलें Rename Workspace वर्कस्पेस का नाम बदलें Rename… नाम बदलें… @@ -4186,7 +4186,7 @@ antiAlias Sharp शार्प Save To: यहाँ सेव करें: Save Transparency Flattener Preset ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें Save Transparency Flattener Preset… ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें… - Save Variable Library… वैरिएबल लाइब्रेरी सेव करें… + Save Variable Library… वेरिएबल लाइब्रेरी सेव करें… Save… सेव… Saved सेव किए गए SAVED सेव किए गए @@ -5358,11 +5358,11 @@ antiAlias Sharp शार्प V Spacing: वी स्पेसिंग: V: V: Value Axis मूल्य एक्सिस - Variable चर + Variable वेरिएबल Variable Font वेरिएबल फ़ॉन्ट - Variable Options वैरिएबल ऑप्शन्स - Variable Options… परिवर्तनीय ऑप्शन्स… - Variable Width Profile वैरिएबल चौड़ाई प्रोफ़ाइल + Variable Options वेरिएबल ऑप्शन्स + Variable Options… वेरिएबल ऑप्शन्स… + Variable Width Profile वेरिएबल चौड़ाई प्रोफ़ाइल Variables वेरिएबल्स Variation वैरिएशन Variation: भिन्नता: @@ -6011,7 +6011,7 @@ antiAlias Sharp शार्प @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए @msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। -@msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है +@msg `ends` applies to joining exactly two paths `ends` ठीक दो पाथ को जोड़ने पर लागू होता है @msg `event` must be one of {_1} `event` is one of the {_1} @msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} @msg `expand` must be true or false `expand` को true या false होना चाहिए @@ -6227,7 +6227,7 @@ antiAlias Sharp शार्प @msg a multi-layer mask needs one clipping path बहुलेयर मास्क को एक क्लिपिंग पाथ की आवश्यकता होती है @msg a multi-layer mask's operators are out of order बहुलेयर मास्क ऑपरेटर क्रम से बाहर हैं @msg a name names one asset: give multiple: false, or one object एक नाम एक ही एसेट को दर्शाता है: multiple: false दें, या एक ऑब्जेक्ट -@msg a new document refuses this file because of these variables: {names} एक नया डॉक्यूमेंट इन चरों के कारण इस फ़ाइल को मना कर देता है: {names} +@msg a new document refuses this file because of these variables: {names} इन वेरिएबल्स के कारण नया डॉक्यूमेंट इस फ़ाइल को स्वीकार नहीं करता: {names} @msg a new document won't read these variables back with their kind: {names} नया डॉक्यूमेंट इन वेरिएबल्स का प्रकार वापस नहीं पढ़ पाएगा: {names} @msg a page can't be read एक पेज पढ़ा नहीं जा सकता @msg a page is missing एक पेज गायब हो रहा है @@ -6279,8 +6279,8 @@ antiAlias Sharp शार्प @msg a toolbar name has at most {MAX_NAME} characters टूलबार नाम में सबसे {MAX_NAME} वर्ण हैं @msg a value has the wrong type for the operator एक मूल्य ऑपरेटर के लिए गलत प्रकार है @msg a value is outside the range the operator takes एक मूल्य सीमा के बाहर है जो ऑपरेटर लेता है -@msg a variable library holds text variables, and the document has none एक परिवर्तनीय लाइब्रेरी टेक्स्ट चर रखती है, और डॉक्यूमेंट में कोई नहीं है -@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार की एक चर ऑब्जेक्ट्स के लिए बाध्य नहीं किया जा सकता है +@msg a variable library holds text variables, and the document has none वेरिएबल लाइब्रेरी में टेक्स्ट वेरिएबल होते हैं, लेकिन डॉक्यूमेंट में कोई नहीं है +@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार का वेरिएबल ऑब्जेक्ट्स से नहीं बाँधा जा सकता @msg a view named `{_1}` exists `{_1}` नामक दृश्य मौजूद है @msg a view named `{name}` exists `{name}` नामक दृश्य मौजूद है @msg a workspace keeps at most {MAX} toolbars एक वर्कस्पेस सबसे {MAX} टूलबार में रहता है @@ -6419,7 +6419,7 @@ antiAlias Sharp शार्प @msg batches cannot nest बैच घोंसला नहीं कर सकते @msg before must name a panel before: पहले एक पैनल का नाम देना चाहिए @msg below and above must name a panel floating in another group नीचे और ऊपर एक पैनल दूसरे समूह में चल रहा है नाम होना चाहिए -@msg bind something to a variable first पहले एक चर के लिए कुछ बांधना +@msg bind something to a variable first पहले किसी ऑब्जेक्ट को वेरिएबल से बाँधें @msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed होना चाहिए 0-{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} @msg Blend colors: select at least three objects with filled colours मिश्रित रंग: भरे हुए रंगों के साथ कम से कम तीन ऑब्जेक्ट चुनें @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP फ़ाइलें RGB या ग्रेस्केल होती हैं: colorModel को rgb या gray रखें @@ -6493,20 +6493,20 @@ antiAlias Sharp शार्प @msg colour groups can't go into colour groups रंग समूह रंग समूहों में नहीं जा सकते हैं @msg colour, mode and spot apply to solid-colour swatches only रंग, मोड और स्पॉट केवल ठोस रंग वाले स्वॉच पर लागू होते हैं @msg column {column}: `{cell}` is a `%` column of graph data files, which are not read कॉलम {column}: `{cell}` ग्राफ़ डेटा फ़ाइल का `%` कॉलम है, जिसे पढ़ा नहीं जाता -@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` नाम `{name}`, जो एक {_1} चर है -@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं -@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: चर नाम {MAX_NAME} अक्षरों से लंबा है +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` में `{name}` नाम का {_1} वेरिएबल है +@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` किसी वेरिएबल का नाम नहीं है +@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: वेरिएबल का नाम {MAX_NAME} कैरेक्टर से लंबा है @msg column docks a set as a dock column, with no anchor or icon कॉलम एक डॉक कॉलम के रूप में सेट करता है, जिसमें कोई एंकर या आइकन नहीं होता है @msg column must be true or a number from 1 column को true या 1 या उससे बड़ी संख्या होना चाहिए @msg column: true floats the whole column and takes no onto, above or below column: true पूरे कॉलम को फ़्लोट करता है और onto, above या below नहीं लेता -@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}` +@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों में वेरिएबल `{name}` का नाम है @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} @msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है @msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश @msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command` @msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए। -@msg compound {_1} must contain exactly one open path to join यौगिक {_1} में शामिल होने के लिए बिल्कुल एक खुला रास्ता होना चाहिए +@msg compound {_1} must contain exactly one open path to join जोड़ने के लिए कंपाउंड {_1} में ठीक एक खुला पाथ होना चाहिए @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं @msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है @@ -6667,11 +6667,11 @@ antiAlias Sharp शार्प @msg data must be an object of key: value, not {v} data: डेटा कुंजी का एक ऑब्जेक्ट होना चाहिए: मान, नहीं {v} @msg Data Recovery can't keep copies in {path}: {e} डेटा रिकवरी {path} में प्रतियां नहीं रख सकती: {e} @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) डेटा रिकवरी की प्रतिलिपियाँ रखने के लिए कहीं नहीं है: निर्धारित प्राथमिकता → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) -@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक नियंत्रण चरित्र होता है, जो एक परिवर्तनीय लाइब्रेरी पकड़ नहीं सकता है +@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक कंट्रोल कैरेक्टर है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता @msg data set `{name}` holds a value for `{var}`, which the library doesn't declare डेटा सेट `{name}` `{var}` के लिए एक मान रखता है, जिसे लाइब्रेरी घोषित नहीं करती है @msg data set `{name}` holds two values for `{var}` डेटा सेट `{name}` में `{var}` के लिए दो मान हैं। @msg data set `{name}` is declared twice डेटा सेट `{name}` को दो बार घोषित किया गया है -@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` शुरू होता है या व्हाइट स्पेस के साथ समाप्त होता है, जो एक परिवर्तनीय लाइब्रेरी को नहीं पकड़ सकता है +@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` की शुरुआत या अंत में व्हाइटस्पेस है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता @msg data takes at most {MAX_DATA} keys at once data: डेटा एक बार में सबसे {MAX_DATA} कुंजी लेता है @msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none प्रोग्राम पढ़ने वाले डेटा को क्षतिग्रस्त या छोटा काट दिया जाता है, या प्रोग्राम एक फ़ाइल में एक स्थिति के लिए पूछता है जिसमें कोई नहीं है। @msg dataset `{name}` already exists डेटासेट `{name}` पहले से मौजूद है @@ -6679,7 +6679,7 @@ antiAlias Sharp शार्प @msg date `{d}`: YYYY-MM-DD दिनांक `{d}`: YYYYY-MM-DD @msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3}) @msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है -@msg define a variable first पहले एक चर को परिभाषित करना +@msg define a variable first पहले एक वेरिएबल परिभाषित करें @msg detect must be true or false detect को true या false होना चाहिए @msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @@ -6999,9 +6999,9 @@ antiAlias Sharp शार्प @msg its Windows metafile preview was opened instead, which may simplify the art इसके बजाय विंडोज मेटाफ़ाइल प्रीव्यू ओपन किया गया था, जो कला को सरल बना सकता है @msg its zlib compression is damaged इसके zlib संपीड़न क्षतिग्रस्त है @msg its Zstandard compression is damaged इसके Zstandard संपीड़न क्षतिग्रस्त है -@msg join needs open paths join: खुले रास्ते की जरूरत -@msg join two different paths दो अलग-अलग पाथ में शामिल हों -@msg Join: a subpath went missing जॉइन: कोई सबपाथ नहीं मिला +@msg join needs open paths जोड़ने के लिए खुले पाथ चाहिए +@msg join two different paths दो अलग पाथ जोड़ें +@msg Join: a subpath went missing जोड़ते समय एक सबपाथ नहीं मिला @msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए @msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps} @msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए @@ -7028,7 +7028,7 @@ antiAlias Sharp शार्प @msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes लाइन {line}: `{var}` के लिए मूल्य {MAX_GRAPH_CSV} बाइट्स से अधिक है @msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान true या false होना चाहिए -@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` के लिए मान इसके परिवर्तनीय प्रकार से मेल खाता होना चाहिए +@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए @msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf @msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें @msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel फ़ाइल में कोई प्रीव्यू के साथ जुड़े चित्र एक प्लेसहोल्डर दिखाते हैं जब तक कि उनकी फ़ाइलें नहीं मिलती: उन्हें लिंक पैनल में फिर से लिंक करें @@ -7143,7 +7143,7 @@ antiAlias Sharp शार्प @msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2}) @msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक @msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक -@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है। +@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक वेरिएबल्स: डेटा सेट आयात अधिकतम {MAX_CSV_COLS} वेरिएबल्स पढ़ता है @msg more than {MAX_CSV_ROWS} data rows {MAX_CSV_ROWS} डेटा पंक्तियों से अधिक @msg more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS} {MAX_CSV_ROWS} डेटा सेट से अधिक: आयात डेटा सेट अधिकांश {MAX_CSV_ROWS} पर पढ़ा जाता है। @msg more than {MAX_FILES} images {MAX_FILES} इमेज से अधिक @@ -7399,10 +7399,10 @@ antiAlias Sharp शार्प @msg no user library folder here यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं @msg no user library folder here (save with a path, or without one to get the data) यहाँ कोई यूज़र लाइब्रेरी फ़ोल्डर नहीं है (पाथ के साथ सेव करें, या डेटा पाने के लिए बिना पाथ के) @msg no user workspace `{from}` कोई उपयोगकर्ता वर्कस्पेस `{from}` -@msg no variable named `{from}` कोई चर नाम `{from}` -@msg no variable named `{name}` कोई चर नाम `{name}` -@msg no variable named `{v}` कोई चर नाम `{v}` -@msg no variable named `{var}` कोई चर नाम `{var}` +@msg no variable named `{from}` `{from}` नाम का कोई वेरिएबल नहीं है +@msg no variable named `{name}` `{name}` नाम का कोई वेरिएबल नहीं है +@msg no variable named `{v}` `{v}` नाम का कोई वेरिएबल नहीं है +@msg no variable named `{var}` `{var}` नाम का कोई वेरिएबल नहीं है @msg no view named `{name}` `{name}` नाम का कोई दृश्य @msg node {_1} is not a graph node {_1} एक graph नहीं है @msg node {_1} is not an image node {_1} एक इमेज नहीं है @@ -7461,7 +7461,7 @@ antiAlias Sharp शार्प @msg not JSON: {e} JSON नहीं है: {e} @msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें @msg nothing encoded कुछ भी नहीं -@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है। +@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} से कुछ भी लागू नहीं हुआ: उसमें मान वाले किसी भी वेरिएबल से कोई ऑब्जेक्ट नहीं बाँधा गया है @msg nothing prints कुछ भी प्रिंट @msg nothing selected कुछ भी नहीं चुना @msg nothing selected is in a group कुछ भी नहीं चुना गया है एक समूह में @@ -7512,7 +7512,7 @@ antiAlias Sharp शार्प @msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1} @msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था -@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, परिवर्तनीय चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है +@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, वेरिएबल चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है @msg offset out of range सीमा से बाहर ऑफसेट @msg Offset Path: {e} ऑफसेट पाथ: {e} @msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ @@ -7669,7 +7669,7 @@ antiAlias Sharp शार्प @msg redraw needs id redraw: ई.डी. की आवश्यकता @msg redraw within must be a finite distance of 0 or more भीतर वापस लेना 0 या उससे अधिक की एक सीमित दूरी होना चाहिए @msg registration must be 0..8 registration को 0..8 होना चाहिए -@msg Rejoin Path Pieces: too many paths to rejoin at once Rejoin पाथ के टुकड़े: एक बार में फिर से जुड़ने के लिए कई पाथ +@msg Rejoin Path Pieces: too many paths to rejoin at once पाथ के हिस्से फिर से जोड़ें: एक बार में फिर से जोड़ने के लिए बहुत अधिक पाथ हैं @msg release a layer or a group एक लेयर या एक समूह को छोड़ दें @msg Release Guides: no guides रिलीज़ गाइड: कोई गाइड नहीं @msg Release: no layer to put the rectangle in रिलीज: आयत को डालने के लिए कोई लेयर नहीं @@ -7736,7 +7736,7 @@ antiAlias Sharp शार्प @msg select a path एक पाथ चुनें @msg select a repeat दोहराने सिलेक्ट करें @msg select a Shaper group एक शेपर समूह चुनें -@msg select a stroke with a variable width एक चर चौड़ाई के साथ एक स्ट्रोक सिलेक्ट करें +@msg select a stroke with a variable width वेरिएबल चौड़ाई वाला स्ट्रोक सिलेक्ट करें @msg select a symbol instance एक सिंबल उदाहरण चुनें @msg select a text object एक टेक्स्ट ऑब्जेक्ट चुनें @msg select an envelope एक लिफाफे सिलेक्ट करें @@ -7781,7 +7781,7 @@ antiAlias Sharp शार्प @msg select at least two objects कम से कम दो ऑब्जेक्ट चुनें @msg Select dimensions to update अद्यतन करने के लिए आयाम सिलेक्ट करें @msg select exactly two objects वास्तव में दो ऑब्जेक्ट चुनें -@msg select exactly two path ends to join वास्तव में दो पाथ में शामिल होने के लिए समाप्त होता है चुनें +@msg select exactly two path ends to join जोड़ने के लिए ठीक दो पाथ एंड पॉइंट्स सिलेक्ट करें @msg Select horizontal point or area type whose text fits ऐसा क्षैतिज पॉइंट टाइप या एरिया टाइप सिलेक्ट करें जिसका टेक्स्ट फ़िट होता है @msg select instances of one symbol एक सिंबल के उदाहरण चुनें @msg select members of a compound shape एक मिश्रित आकार के सदस्यों सिलेक्ट करें @@ -7798,7 +7798,7 @@ antiAlias Sharp शार्प @msg select paths पाथ चुनें @msg select paths or anchor points पाथ या एंकर अंक चुनें @msg select paths or compound paths पाथ या यौगिक पाथ सिलेक्ट करें -@msg select paths to join शामिल होने के लिए पाथ सिलेक्ट करें +@msg select paths to join जोड़ने के लिए पाथ सिलेक्ट करें @msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें @msg select paths, or segments, to divide विभाजित करने के लिए पाथ, या खंड चुनें @msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें @@ -7827,7 +7827,7 @@ antiAlias Sharp शार्प @msg select the objects to attach to the path ऑब्जेक्ट्स का सिलेक्ट करने के लिए संलग्न करने के लिए पाथ @msg select the objects to bind ऑब्जेक्ट्स को बांधने के लिए चुनें @msg select the objects to transform in perspective ऑब्जेक्ट्स को पर्सपेक्टिव में बदलने के लिए चुनें -@msg select two open path ends, or two open paths, to average and join दो खुले पाथ सिलेक्ट करें, या दो खुले रास्ते, औसतन और जुड़ना +@msg select two open path ends, or two open paths, to average and join औसत निकालकर जोड़ने के लिए दो खुले पाथ एंड पॉइंट्स या दो खुले पाथ सिलेक्ट करें @msg select two or more graphic styles to merge दो या अधिक ग्राफिक स्टाइल्स का सिलेक्ट करने के लिए विलय @msg select two or more objects to distribute वितरित करने के लिए दो या दो से अधिक ऑब्जेक्ट चुनें @msg select two or more objects to intertwine दो या दो से अधिक ऑब्जेक्ट्स सिलेक्ट करें @@ -7884,7 +7884,7 @@ antiAlias Sharp शार्प @msg strokeOptions must be an object of stroke.set options strokeOptions को stroke.set ऑप्शन्स का ऑब्जेक्ट होना चाहिए @msg strokeWidth must be a positive number of pixels strokeWidth को पिक्सेल की धनात्मक संख्या होना चाहिए @msg subpath {si} is not an open subpath उपपाथ {si} एक खुला उपपाथ नहीं है -@msg subpaths required when joining the same path एक ही पाथ में शामिल होने पर आवश्यक उप-पाथ +@msg subpaths required when joining the same path एक ही पाथ को जोड़ने के लिए सबपाथ चाहिए @msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG UTF-8, UTF-16 या ISO 8859-1 नहीं है। @msg SVG parse error: {0} SVG पार्स त्रुटि: {0} @msg swatchConflict must be "merge" or "add", not {v} swatchConflict को "merge" या "add" होना चाहिए, {v} नहीं @@ -8039,9 +8039,9 @@ antiAlias Sharp शार्प @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं @msg the file's preview header points outside the file फ़ाइल के प्रीव्यू शीर्षलेख फ़ाइल के बाहर अंक -@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल के परिवर्तनीय बंधन सभी नहीं पढ़े गए थे (बाइंडिंग्स स्किप: {_1}): वे ऑब्जेक्ट अपने चर के लिए बाध्य नहीं हैं -@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल के परिवर्तनीय लाइब्रेरी को पढ़ा नहीं जा सकता: चर पैनल इसे सूचीबद्ध नहीं करता है -@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की परिवर्तनीय लाइब्रेरी आंशिक रूप से पढ़ी गई थी (प्रवेशियों को छोड़ दिया: {_1}): वेरिएबल्स पैनल बाकी हिस्सों को सूचीबद्ध करता है +@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल की कुछ वेरिएबल बाइंडिंग नहीं पढ़ी गईं (छोड़ी गई बाइंडिंग: {_1}): वे ऑब्जेक्ट अपने वेरिएबल्स से नहीं बँधे हैं +@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल की वेरिएबल लाइब्रेरी पढ़ी नहीं जा सकी: वेरिएबल्स पैनल उसे सूचीबद्ध नहीं करता +@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की वेरिएबल लाइब्रेरी आंशिक रूप से पढ़ी गई (छोड़ी गई प्रविष्टियाँ: {_1}): वेरिएबल्स पैनल शेष प्रविष्टियाँ दिखाता है @msg the files come to more than 1 GB: add fewer at a time फ़ाइलों को 1 GB से अधिक करने के लिए आते हैं: एक समय में कम जोड़ें @msg the first character of a line has no character before it to kern: it moves only up and down (dy) एक पंक्ति के पहले चरित्र में बर्न से पहले कोई चरित्र नहीं है: यह केवल ऊपर और नीचे चला जाता है (डी) @msg the font {_1} is not available फ़ॉन्ट {_1} उपलब्ध नहीं है @@ -8054,7 +8054,7 @@ antiAlias Sharp शार्प @msg The graphics were lost and couldn't be restarted. ग्राफिक्स खो गए थे और फिर से शुरू नहीं किया जा सकता था। @msg The graphics were lost and have been restarted ग्राफिक्स खो गए थे और फिर से शुरू किया गया है @msg the guide is on a hidden or locked layer गाइड एक छिपे हुए या बंद लेयर पर है -@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस चर `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस वेरिएबल `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें @msg the header `{header}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` का हेडर `{header}` दोबारा पढ़ने पर वेरिएबल `{other}` बन जाएगा: किसी एक का नाम बदलें @msg the image `{path}` isn't followed: {r} इमेज `{path}` का पालन नहीं किया गया है: {r} @msg the image can't be read as a texture इमेज को बनावट के रूप में नहीं पढ़ा जा सकता है @@ -8219,14 +8219,14 @@ antiAlias Sharp शार्प @msg the trailer has no size ट्रेलर में कोई आकार नहीं है @msg the type object is flattened to nothing फ़्लैटन करने पर टाइप ऑब्जेक्ट में कुछ नहीं रहता @msg the value of axis `{tag}` must be a number or null धुरी का मूल्य `{tag}` होना चाहिए एक नंबर या null होना चाहिए -@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_DATASETS} डेटा सेट से अधिक घोषित करता है +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets वेरिएबल लाइब्रेरी में {MAX_LIBRARY_DATASETS} से अधिक डेटा सेट घोषित हैं @msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities वेरिएबल लाइब्रेरी {MAX_LIBRARY_ENTITIES} संस्थाओं से अधिक की घोषणा करती है। -@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_VARIABLES} चर से अधिक घोषित करता है। +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables वेरिएबल लाइब्रेरी {MAX_LIBRARY_VARIABLES} से अधिक वेरिएबल्स घोषित करती है @msg the variable library declares no variables वेरिएबल लाइब्रेरी ने कोई वेरिएबल घोषित नहीं किया है -@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट्स से बड़ा है -@msg the variable library is not UTF-8 चर लाइब्रेरी UTF-8 नहीं है +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट से बड़ी है +@msg the variable library is not UTF-8 वेरिएबल लाइब्रेरी UTF-8 में नहीं है @msg the variable library is not well-formed XML: {e} वेरिएबल लाइब्रेरी अच्छी तरह से निर्मित XML नहीं है: {e} -@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep परिवर्तनीय लाइब्रेरी घोंसला तत्वों से अधिक {MAX_LIBRARY_NESTING} गहरा +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep वेरिएबल लाइब्रेरी में एलिमेंट {MAX_LIBRARY_NESTING} स्तर से अधिक गहराई तक नेस्ट किए गए हैं @msg the variable library's document type declaration holds something other than entities of plain text वेरिएबल लाइब्रेरी के डॉक्यूमेंट प्रकार की घोषणा सादे टेक्स्ट की संस्थाओं के अलावा कुछ अन्य रखती है @msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी की संस्थाएं {MAX_LIBRARY_BYTES} बाइट्स से अधिक विस्तार करती हैं @msg the window has no dock विंडो में कोई डॉक नहीं है @@ -8445,14 +8445,14 @@ antiAlias Sharp शार्प @msg unsupported: {0} unsupported: {0} @msg use paths or compound paths पाथ या यौगिक पाथ का उपयोग करें @msg useArtboards must be true or false, not {v} useArtboards को true या false होना चाहिए, {v} नहीं -@msg value for `{var}` must match its variable kind `{var}` के लिए मूल्य अपने परिवर्तनीय प्रकार से मेल खाता होना चाहिए -@msg variable `{_1}` is already defined चर `{_1}` पहले से ही परिभाषित किया गया है +@msg value for `{var}` must match its variable kind `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए +@msg variable `{_1}` is already defined वेरिएबल `{_1}` पहले से परिभाषित है @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` वेरिएबल `{name}` वेरिएबल लाइब्रेरी में सेव किया नहीं जा सकता: नाम अक्षर या `_` से शुरू करें, स्पेस और `-`, `_`, `.` के अलावा अन्य चिह्न न रखें -@msg variable `{name}` has no `trait` चर `{name}` में कोई `trait` नहीं है -@msg variable `{name}` is already defined चर `{name}` को पहले से ही परिभाषित किया गया है -@msg variable `{name}` is declared twice परिवर्तनीय `{name}` को दो बार घोषित किया गया है -@msg variable `{to}` already exists चर `{to}` पहले से मौजूद -@msg variables are locked चर लॉक कर रहे हैं +@msg variable `{name}` has no `trait` वेरिएबल `{name}` में `trait` नहीं है +@msg variable `{name}` is already defined वेरिएबल `{name}` पहले से परिभाषित है +@msg variable `{name}` is declared twice वेरिएबल `{name}` दो बार घोषित है +@msg variable `{to}` already exists वेरिएबल `{to}` पहले से मौजूद है +@msg variables are locked वेरिएबल्स लॉक हैं @msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds} @msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है @msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म) @@ -8622,7 +8622,7 @@ file-filter Swatch libraries स्वॉच लाइब्रेरीज़ file-filter Templates टेम्पलेट्स file-filter VectorCraft Template वेक्टर क्राफ्ट टेम्पलेट font-script Japanese जापानी -font-technology Variable चर +font-technology Variable वेरिएबल gradient-kind Radial रेडियल imageTrace Outline रूपरेखा lighting Light लाइट diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a18f4d1a58..cbcc69af68 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -63,6 +63,36 @@ fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n")); } +/// Every Hindi row that names a variable says वेरिएबल, never चर, परिवर्तनीय or वैरिएबल, and joining +/// paths, points, type or lines is जोड़ें, never शामिल हों, which stays for joining the community. +#[test] +fn hindi_rows_use_one_term_for_a_variable_and_for_joining_paths() { + let hi = Lang::from_code("hi").expect("hi registered"); + let (entries, _) = parse_entries(hi.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in &entries { + let english = src.to_lowercase(); + let words: Vec<&str> = english.split(|c: char| !c.is_ascii_alphabetic()).collect(); + let hindi: Vec<&str> = translation.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation() || "।…".contains(c)).collect(); + let names = |list: &[&str]| words.iter().any(|w| list.contains(w)); + let other_variable = hindi.iter().any(|w| ["चर", "चरों", "परिवर्तनीय"].contains(w)) || translation.contains("वैरिएबल"); + if names(&["variable", "variables"]) && (other_variable || !translation.contains("वेरिएबल")) { + wrong.push(format!("variable in {src:?} -> {translation:?}")); + } + if names(&["join", "joining"]) && !names(&["discord"]) && translation.contains("शामिल") { + wrong.push(format!("join in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n")); + assert_eq!(tr(hi, "Variable"), "वेरिएबल"); + assert_eq!(tr(hi, "Load Variable Library…"), "वेरिएबल लाइब्रेरी लोड करें…"); + assert_eq!(tr(hi, "Join"), "जोड़ें"); + assert_eq!(tr(hi, "Close or Join"), "बंद करें या जोड़ें"); + assert_eq!(message(hi, "join needs open paths"), "जोड़ने के लिए खुले पाथ चाहिए"); + assert_eq!(message(hi, "Join: a subpath went missing"), "जोड़ते समय एक सबपाथ नहीं मिला"); + assert_eq!(tr(hi, "Join our Discord"), "हमारे डिसकॉर्ड में शामिल हों"); +} + /// Hindi messages and labels keep the condition, quantity and clauses of their English. #[test] fn hindi_rows_keep_the_meaning_of_their_english() { diff --git a/docs/localization-hi.md b/docs/localization-hi.md index d8dbcaf145..ad59a036a9 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -32,6 +32,14 @@ every clause such as "all" or "what follows it". Type as a text object is टा in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms and the point unit in every row. +A variable is वेरिएबल (plural वेरिएबल्स) in every sense: a data-merge variable and the +variable library, a variable font, a variable stroke width and an environment variable. It is +never चर, परिवर्तनीय or वैरिएबल. Joining paths, anchor points, point type or the lines of a +paragraph is जोड़ें (जोड़ने के लिए in a purpose clause); शामिल हों is kept for joining the +community. The stroke corner names (Bevel Join, Miter Join, Round Join) and the Join tool keep +जॉइन. `hindi_rows_use_one_term_for_a_variable_and_for_joining_paths` checks both terms in every +row. + ## Terminology | English | Hindi | @@ -52,6 +60,8 @@ and the point unit in every row. | Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन | | Appearance / Graphic style | अपीयरेंस / ग्राफ़िक स्टाइल | | Symbol / Envelope | सिंबल / एन्वेलप | +| Variable / Variables / Variable library | वेरिएबल / वेरिएबल्स / वेरिएबल लाइब्रेरी | +| Join (paths, points, type, lines) | जोड़ें | | Clipping mask / Opacity mask | क्लिपिंग मास्क / ओपेसिटी मास्क | | Image Trace / Live Paint | इमेज ट्रेस / लाइव पेंट | | Shape Builder / Pathfinder | शेप बिल्डर / पाथफ़ाइंडर | @@ -69,7 +79,7 @@ The draft has 7,877 unique rows, including 2,537 `@msg`, 45 `@msgplural` and 38 Its source set combines the sibling catalogs with the current test scanner's UI/undo and error message dumps, so it includes runtime labels outside the literal scanner. Row count is not a percentage of the application's entire interface. -On 2026-11-13 the catalog has 8,732 rows, including 3,003 `@msg`, 47 `@msgplural` and 41 +On 2026-11-13 the catalog has 8,782 rows, including 3,014 `@msg`, 47 `@msgplural` and 41 `@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in messages for meaning, dropped clauses, English left in place and the save, swatch, crop, resolution, path and type terms. It is not a native proofread. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index db3d26f7ce..cd8dc33f30 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -29,7 +29,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | +| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..c67a14632e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -859,6 +859,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged. + - Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). From 9a4e3e620237ea2ae36474d37169c0dde9290f72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:41:00 -0800 Subject: [PATCH 3/3] Smooth tool: the drag shows the band the tool smooths While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band its release smooths: every point within the tool's radius (12 screen pixels) of the drag, round at both ends. Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. A selection with no path the tool can change shows none. - The band is kept in squares eight radii wide, and each step of the drag adds its area only to the squares it reaches. A piece of a square's outline along a side is left out only where the square across holds band area. - A drag segment too long for the squares (a jump drawn zoomed out and viewed zoomed in) keeps its band near each end in the squares and shows the rest as the two sides of its band. A step that cannot be added whole is added one segment at a time. - A coarser grid of cells lists the squares and long segments, so a new one is checked only against those near it, and an outline is made again only where the band near it changed. - After a zoom change mid-drag the band is outlined again a few hundred points per frame, and the work on long segments stops for the frame after a fixed budget. Tools report unfinished overlays with Tool::overlays_pending, and the canvas then draws again on the next frame, so the band covers the whole drag even while the pointer holds still. --- crates/engine/src/tooling.rs | 6 + crates/tools/src/draw2/gesture.rs | 676 +++++++++++++++++++++++- crates/tools/src/draw2/gesture/tests.rs | 437 ++++++++++++++- crates/tools/src/lib.rs | 6 + crates/ui-egui/src/canvas.rs | 4 + docs/target-app-parity.md | 4 +- 6 files changed, 1128 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 0452251a09..ae0c55ac76 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -804,6 +804,12 @@ impl Session { self.tool.wants_ticks() } + /// Did the active tool leave part of its overlays for the next frame + /// ([`vectorcraft_tools::Tool::overlays_pending`])? + pub fn tool_overlays_pending(&self) -> bool { + self.tool.overlays_pending() + } + /// Does the active tool build a stroke from every position the pointer passed /// ([`vectorcraft_tools::Tool::wants_samples`])? pub fn tool_wants_samples(&self) -> bool { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index d426044379..2de44f97ba 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -23,9 +23,13 @@ //! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that //! line to 45° steps from the canvas axes. +use std::cell::Cell; +use std::collections::{BTreeMap, BTreeSet}; + use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use vectorcraft_geom::{Affine, Point}; +use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes}; +use vectorcraft_pathops as po; use super::{FEEDBACK, blob_area, blob_tip, eraser_area, eraser_tip, points_json, polyline}; use crate::guides::DrawSnap; @@ -35,6 +39,32 @@ use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, T /// Pencil, Paintbrush or Blob Brush uses the same radius. const SMOOTH_RADIUS: f64 = 12.0; +/// The most points of a drag that one step of its band adds, as when the band is outlined again +/// at a new zoom. +const BAND_STEP: usize = 32; +/// The most steps one call adds to a band: a band outlined again for a long drag shows in full +/// over several frames ([`Tool::overlays_pending`]). +const BAND_STEPS_PER_CALL: usize = 8; +/// The side of the squares a smoothing drag's band is kept in, in radii: a step of the drag adds +/// its area only to the squares it reaches, so the work of a step does not grow with the drag. +const BAND_TILE_RADII: f64 = 8.0; +/// Where the grid of those squares starts, in squares from the origin, so that the edge of the +/// band of a drag along whole coordinates does not run along a side of a square. +const BAND_TILE_OFFSET: f64 = 0.381_966; +/// The most squares one segment of a drag is checked against: a segment longer than 2,048 squares +/// is kept outside the squares, and shows as the two sides of its band. +const BAND_TILE_SAMPLES: f64 = 4096.0; +/// The side of the cells of a coarser grid, in squares: each cell lists the squares and the long +/// segments in it, so that a new square or long segment is checked only against those near it. +const BAND_CELL_TILES: i64 = 1024; +/// The most points, half a cell apart, along a long segment at which its cells are listed: a +/// longer segment is listed in no cell and checked against every square and long segment. +const BAND_CELL_SAMPLES: f64 = 4096.0; +/// The most work one call does on the band's long segments: comparisons of a long segment with a +/// square or another long segment, and pieces of an outline tested. The rest waits for the next +/// call ([`Tool::overlays_pending`]), and the band shown until then is the last one finished. +const BAND_WORK_PER_CALL: usize = 20_000; + /// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn: /// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and /// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity. @@ -136,6 +166,584 @@ pub struct GestureTool { /// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas, /// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool. tip_at: Option, + /// The band the smoothing drag has shown so far ([`GestureTool::smooth_band`]). + band: Cell>, + /// The band last shown does not cover the whole drag yet; the next call adds to it. + band_pending: Cell, +} + +/// The band a smoothing drag smooths, for the drag's first `used` points at `radius`, kept as its +/// area in each square of a grid that it reaches. +struct SmoothBand { + radius: f64, + used: usize, + first: Point, + last: Point, + tiles: BTreeMap<(i64, i64), BandTile>, + /// The segments of the drag too long for the squares ([`BAND_TILE_SAMPLES`]). + long: Vec, + /// The squares and long segments listed in each cell of a coarser grid ([`BAND_CELL_TILES`]). + cells: BTreeMap<(i64, i64), BandCell>, + /// The long segments listed in no cell ([`BAND_CELL_SAMPLES`]). + wide: Vec, + /// The squares near a long segment whose `cut` is to be made again. + dirty_tiles: BTreeSet<(i64, i64)>, + /// The long segments whose `drawn` is to be made again. + dirty_long: BTreeSet, + /// The band changed since `shown` was made. + changed: bool, + /// The outline last finished. + shown: Option, + /// The work done on long segments in the current call ([`BAND_WORK_PER_CALL`]). + work: usize, +} + +/// The squares and long segments (indexes into [`SmoothBand::long`]) in one cell of the coarser grid. +#[derive(Default)] +struct BandCell { + tiles: Vec<(i64, i64)>, + long: Vec, +} + +/// The part of a band in one square of the grid, and its outline less the square's sides. +struct BandTile { + area: PathData, + outline: BezPath, + /// The long segments whose band can reach the square (indexes into [`SmoothBand::long`]). + near_long: Vec, + /// `outline` less its parts inside the band of a long segment in `near_long`. + cut: BezPath, +} + +/// A segment of a drag too long for the squares, from `a` to `b`, with the part of it outside the +/// squares (`from` and `to`: 0 at `a`, 1 at `b`): the squares hold its band for a square's side +/// from each end ([`SmoothBand::add_long`]). +struct LongSegment { + a: Point, + b: Point, + from: f64, + to: f64, + /// The squares its band can reach. + near: BTreeSet<(i64, i64)>, + /// The other long segments whose band can meet its band. + meets: Vec, + /// The parts of the two sides of its band that show. + drawn: BezPath, +} + +impl LongSegment { + /// The two sides of its band at `radius`, over its part outside the squares. + fn sides(&self, radius: f64) -> [vectorcraft_geom::Line; 2] { + let d = self.b - self.a; + let s = Vec2::new(-d.y, d.x) * (radius / d.hypot()); + let (p, q) = (self.a.lerp(self.b, self.from), self.a.lerp(self.b, self.to)); + [vectorcraft_geom::Line::new(p + s, q + s), vectorcraft_geom::Line::new(p - s, q - s)] + } +} + +impl SmoothBand { + /// The band of a drag that starts at `first`, before it has moved. + fn new(first: Point, radius: f64) -> Self { + Self { + radius, + used: 1, + first, + last: first, + tiles: BTreeMap::new(), + long: vec![], + cells: BTreeMap::new(), + wide: vec![], + dirty_tiles: BTreeSet::new(), + dirty_long: BTreeSet::new(), + changed: true, + shown: None, + work: 0, + } + } + + /// The cell of the coarser grid that holds square `key`. + fn cell_of((ix, iy): (i64, i64)) -> (i64, i64) { + (ix.div_euclid(BAND_CELL_TILES), iy.div_euclid(BAND_CELL_TILES)) + } + + /// The cells around points half a cell apart along the segment from `a` to `b`: every cell + /// holding a square its band can reach, or a point within the radius of its band. `None` for + /// a segment longer than [`BAND_CELL_SAMPLES`] half cells or reaching far outside any document. + fn cells_along(&self, a: Point, b: Point) -> Option> { + let n = (a.distance(b) / (0.5 * BAND_CELL_TILES as f64 * self.side())).ceil().max(1.0); + if !n.is_finite() || n > BAND_CELL_SAMPLES { + return None; + } + let mut cells = BTreeSet::new(); + for k in 0..=(n as u32) { + let (cx, cy) = Self::cell_of(self.tile_of(a.lerp(b, f64::from(k) / n))?); + for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + cells.insert((cx + dx, cy + dy)); + } + } + Some(cells) + } + + /// Whether the band of the segment from `a` to `b` can reach square `key`. + fn reaches(&self, key: (i64, i64), a: Point, b: Point) -> bool { + distance_to_segment(self.tile_rect(key).center(), a, b) <= self.radius + 0.5 * std::f64::consts::SQRT_2 * self.side() + } + + /// Whether the segment from `a` to `b` is too long for the squares: longer than + /// [`BAND_TILE_SAMPLES`] half squares. + fn too_long(&self, a: Point, b: Point) -> bool { + let n = a.distance(b) / (0.5 * self.side()); + n.is_finite() && n > BAND_TILE_SAMPLES + } + + /// Add the band of the segment from `a` to `b`, too long for the squares: the squares take its + /// band for a square's side from each end, where they can, and the rest is kept as the + /// segment. + fn add_long(&mut self, a: Point, b: Point) { + let d = b - a; + let stub = d * (self.side() / d.hypot()); + let from = if self.grow(&[a, a + stub]).is_some() { self.side() / d.hypot() } else { 0.0 }; + let to = if self.grow(&[b - stub, b]).is_some() { 1.0 - self.side() / d.hypot() } else { 1.0 }; + let i = self.long.len(); + // The squares and long segments listed in its cells, or all of them. + let mut tiles = BTreeSet::new(); + let mut others: BTreeSet = self.wide.iter().copied().collect(); + match self.cells_along(a, b) { + Some(cells) => { + self.work += cells.len(); + for c in cells { + let cell = self.cells.entry(c).or_default(); + tiles.extend(cell.tiles.iter().copied()); + others.extend(cell.long.iter().copied()); + cell.long.push(i); + } + } + None => { + tiles.extend(self.tiles.keys().copied()); + others.extend(0..i); + self.wide.push(i); + } + } + self.work += tiles.len() + others.len(); + let near: BTreeSet<_> = tiles.into_iter().filter(|&k| self.reaches(k, a, b)).collect(); + for k in &near { + if let Some(t) = self.tiles.get_mut(k) { + t.near_long.push(i); + self.dirty_tiles.insert(*k); + } + } + let reach = 2.0 * self.radius + 2e-3 * self.radius; + let meets: Vec = others.into_iter().filter(|&j| self.long.get(j).is_some_and(|o| segment_distance(a, b, o.a, o.b) <= reach)).collect(); + for &j in &meets { + if let Some(o) = self.long.get_mut(j) { + o.meets.push(i); + self.dirty_long.insert(j); + } + } + self.long.push(LongSegment { a, b, from, to, near, meets, drawn: BezPath::new() }); + self.dirty_long.insert(i); + self.last = b; + self.changed = true; + } + + /// The side of the grid's squares. + fn side(&self) -> f64 { + BAND_TILE_RADII * self.radius + } + + /// The square of the grid `key` names. + fn tile_rect(&self, (ix, iy): (i64, i64)) -> Rect { + let s = self.side(); + let (x0, y0) = ((ix as f64 + BAND_TILE_OFFSET) * s, (iy as f64 + BAND_TILE_OFFSET) * s); + Rect::new(x0, y0, x0 + s, y0 + s) + } + + /// The square of the grid that holds `p`; `None` far outside any document. + fn tile_of(&self, p: Point) -> Option<(i64, i64)> { + let s = self.side(); + let (x, y) = ((p.x / s - BAND_TILE_OFFSET).floor(), (p.y / s - BAND_TILE_OFFSET).floor()); + (x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64)) + } + + /// The squares within the radius of `piece`: those around points along it half a square apart. + fn tiles_near(&self, piece: &[Point]) -> Option> { + let mut keys = BTreeSet::new(); + for w in piece.windows(2) { + let n = (w[0].distance(w[1]) / (0.5 * self.side())).ceil().max(1.0); + if !n.is_finite() || n > BAND_TILE_SAMPLES { + return None; + } + for k in 0..=(n as u32) { + let (ix, iy) = self.tile_of(w[0].lerp(w[1], f64::from(k) / n))?; + for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + keys.insert((ix + dx, iy + dy)); + } + } + } + Some(keys) + } + + /// Add the area within the radius of `piece`, the drag from the last point covered so far, to + /// the squares it reaches. `None`, with the band unchanged, when the outlines cannot be + /// combined. + fn grow(&mut self, piece: &[Point]) -> Option<()> { + let (r, nz) = (self.radius, FillRule::NonZero); + let end = *piece.last()?; + // Within a hundredth of the radius at any zoom. + let precision = (0.01 * r).min(po::DEFAULT_PRECISION); + let keys = self.tiles_near(piece)?; + let added = blob_area(piece, 2.0 * r, 0.0, 100.0); + let mut changed = vec![]; + for key in keys { + let rect = self.tile_rect(key); + let clip = po::try_boolean(&added, nz, &shapes::rectangle(rect), nz, po::BoolOp::Intersect, precision).ok()?; + if clip.is_empty() { + continue; + } + let area = match self.tiles.get(&key) { + Some(t) => po::try_boolean(&t.area, nz, &clip, nz, po::BoolOp::Union, precision).ok().filter(|a| !a.is_empty())?, + None => clip, + }; + changed.push((key, area)); + } + // A square's outline depends on the squares across its sides, so those are outlined + // again too. + let mut redo = BTreeSet::new(); + for (key @ (ix, iy), area) in changed { + redo.extend([(ix, iy), (ix - 1, iy), (ix + 1, iy), (ix, iy - 1), (ix, iy + 1)]); + let near_long = match self.tiles.get(&key) { + Some(t) => t.near_long.clone(), + None => { + // The long segments listed in the square's cell, and those in no cell. + let cell = self.cells.entry(Self::cell_of(key)).or_default(); + cell.tiles.push(key); + let mut listed: BTreeSet = cell.long.iter().copied().collect(); + listed.extend(self.wide.iter().copied()); + let near: Vec = listed.into_iter().filter(|&i| self.long.get(i).is_some_and(|l| self.reaches(key, l.a, l.b))).collect(); + for &i in &near { + if let Some(l) = self.long.get_mut(i) { + l.near.insert(key); + } + } + near + } + }; + // The sides of those long segments are cut where the square's area holds them. + self.dirty_long.extend(near_long.iter().copied()); + self.tiles.insert(key, BandTile { area, outline: BezPath::new(), near_long, cut: BezPath::new() }); + } + self.changed = true; + let outlines: Vec<_> = redo.into_iter().filter(|k| self.tiles.contains_key(k)).map(|k| (k, self.tile_outline(k))).collect(); + for (key, outline) in outlines { + if let Some(t) = self.tiles.get_mut(&key) { + t.outline = outline; + if !t.near_long.is_empty() { + self.dirty_tiles.insert(key); + } + } + } + self.last = end; + Some(()) + } + + /// The band's outline: the last one finished. While the band has changed since, the parts it + /// changed are made again, until [`BAND_WORK_PER_CALL`] is spent in the call; when all are + /// made, the outline is joined from them ([`SmoothBand::make_outline`]). + fn outline(&mut self) -> Option { + while self.changed { + if !self.dirty_tiles.is_empty() || !self.dirty_long.is_empty() { + if self.work >= BAND_WORK_PER_CALL { + break; + } + if let Some(k) = self.dirty_tiles.pop_first() { + let (cut, work) = self.tile_cut(k); + self.work += work; + if let Some(t) = self.tiles.get_mut(&k) { + t.cut = cut; + } + } else if let Some(i) = self.dirty_long.pop_first() { + let (drawn, work) = self.long_drawn(i); + self.work += work; + if let Some(l) = self.long.get_mut(i) { + l.drawn = drawn; + } + } + } else { + self.shown = Some(self.make_outline()); + self.changed = false; + } + } + self.shown.clone() + } + + /// The band's outline: every square's, joined, then the parts that show of the two sides of + /// the band of each segment too long for the squares. A square near such a segment shows its + /// `cut` ([`SmoothBand::tile_cut`]), and such a segment its `drawn` ([`SmoothBand::long_drawn`]). + fn make_outline(&self) -> BezPath { + let tiles = self.tiles.values().flat_map(|t| (if t.near_long.is_empty() { &t.outline } else { &t.cut }).elements().iter().copied()); + BezPath::from_vec(tiles.chain(self.long.iter().flat_map(|l| l.drawn.elements().iter().copied())).collect()) + } + + /// The outline of square `key` less its parts inside the band of a long segment near it (more + /// than a thousandth of the radius in), and the work that took. + fn tile_cut(&self, key: (i64, i64)) -> (BezPath, usize) { + let Some(t) = self.tiles.get(&key) else { return (BezPath::new(), 1) }; + let (r, eps) = (self.radius, 1e-3 * self.radius); + let near: Vec<&LongSegment> = t.near_long.iter().filter_map(|&i| self.long.get(i)).collect(); + let mut out = BezPath::new(); + let mut pen = None; + let mut work = 1; + for seg in t.outline.segments() { + // The pieces of the segment between the points where it crosses a side of the band + // of a long segment, each left out when it lies inside that band. + let mut ts: Vec = near + .iter() + .flat_map(|l| l.sides(r)) + .flat_map(|line| seg.intersect_line(line)) + .map(|x| x.segment_t) + .filter(|t| *t > 0.0 && *t < 1.0) + .collect(); + ts.extend([0.0, 1.0]); + ts.sort_by(f64::total_cmp); + ts.dedup(); + work += near.len() * ts.len(); + for w in ts.windows(2) { + let piece = if ts.len() == 2 { seg } else { seg.subsegment(w[0]..w[1]) }; + let m = piece.eval(0.5); + if near.iter().any(|l| distance_to_segment(m, l.a, l.b) < r - eps) { + pen = None; + } else { + push_seg(&mut out, &mut pen, piece); + } + } + } + (out, work) + } + + /// The parts that show of the two sides of the band of long segment `i`, outside the squares' + /// part of it, and the work that took. Left out: the parts inside the band area of a square, + /// inside the band of another long segment (more than a thousandth of the radius in), and on + /// a side of an earlier long segment, which already shows. Only the squares and long segments + /// near it are checked. + fn long_drawn(&self, i: usize) -> (BezPath, usize) { + let Some(l) = self.long.get(i) else { return (BezPath::new(), 1) }; + let (r, eps) = (self.radius, 1e-3 * self.radius); + let others: Vec<(usize, &LongSegment)> = l.meets.iter().filter_map(|&j| self.long.get(j).map(|o| (j, o))).collect(); + // Whether a square's band area holds `q`. + let in_tiles = |q: Point| { + self.tile_of(q).and_then(|k| self.tiles.get(&k)).is_some_and(|t| vectorcraft_geom::Shape::winding(&t.area.to_bezpath(), q) != 0) + }; + let mut out = BezPath::new(); + let mut work = 1; + for line in l.sides(r) { + // Where the side crosses the band area of a square it passes, a side of another long + // segment's band, or the end of a side it runs along. + let mut ts = vec![0.0, 1.0]; + for t in l.near.iter().filter_map(|k| self.tiles.get(k)) { + ts.extend(t.area.to_bezpath().segments().flat_map(|seg| seg.intersect_line(line)).map(|x| x.line_t)); + } + // The stretches of the side inside the band of another long segment, and those on a + // side of an earlier one. + let mut inside = vec![]; + let mut along = vec![]; + for &(j, other) in &others { + inside.extend(capsule_span(line, other.a, other.b, r - eps)); + for s in other.sides(r) { + ts.extend(PathSeg::Line(s).intersect_line(line).into_iter().map(|x| x.line_t)); + if j < i { + ts.extend( + [s.p0, s.p1] + .into_iter() + .filter(|&p| distance_to_segment(p, line.p0, line.p1) <= eps) + .map(|p| (p - line.p0).dot(line.p1 - line.p0) / (line.p1 - line.p0).hypot2()), + ); + along.extend(capsule_span(line, s.p0, s.p1, eps)); + } + } + } + let (inside, along) = (merge_spans(inside), merge_spans(along)); + ts.retain(|t| (0.0..=1.0).contains(t)); + ts.sort_by(f64::total_cmp); + ts.dedup(); + work += l.near.len() + others.len() + ts.len(); + let mut pen = None; + for w in ts.windows(2) { + let (p, q) = (line.p0.lerp(line.p1, w[0]), line.p0.lerp(line.p1, w[1])); + let mid = 0.5 * (w[0] + w[1]); + let hidden = in_tiles(p.midpoint(q)) || spans_cover(&inside, mid, mid) || spans_cover(&along, w[0], w[1]); + if !hidden { + push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q))); + } + } + } + (out, work) + } + + /// The outline of the band's part in square `key`, less the parts along a side of the square + /// (within a thousandth of the radius) where the band goes on across that side: where the + /// square across it holds band area a thousandth of the radius past it. + fn tile_outline(&self, key: (i64, i64)) -> BezPath { + let Some(tile) = self.tiles.get(&key) else { return BezPath::new() }; + let (rect, eps) = (self.tile_rect(key), 1e-3 * self.radius); + let (ix, iy) = key; + // A point's distance across a side (x for a side that runs up and down) and along it. + let split = |upright: bool, p: Point| if upright { (p.x, p.y) } else { (p.y, p.x) }; + // Each side with band area across it: whether it runs up and down, where it runs, the + // direction away from this square, the area across, and where along the side that area's + // outline has a point on the side (where the area across can start or stop). + let sides: Vec<_> = [ + (true, rect.x0, (ix - 1, iy), -1.0), + (true, rect.x1, (ix + 1, iy), 1.0), + (false, rect.y0, (ix, iy - 1), -1.0), + (false, rect.y1, (ix, iy + 1), 1.0), + ] + .into_iter() + .filter_map(|(upright, at, k, away)| { + let area = self.tiles.get(&k)?.area.to_bezpath(); + let cuts: Vec = area + .segments() + .flat_map(|s| [s.start(), s.end()]) + .map(|p| split(upright, p)) + .filter(|&(off, _)| (off - at).abs() <= eps) + .map(|(_, along)| along) + .collect(); + Some((upright, at, away, area, cuts)) + }) + .collect(); + let mut out = BezPath::new(); + let mut pen = None; + for seg in tile.area.to_bezpath().segments() { + let (a, b, mid) = (seg.start(), seg.end(), seg.eval(0.5)); + let Some((upright, at, away, area, cuts)) = sides.iter().find(|s| [a, b, mid].iter().all(|&p| (split(s.0, p).0 - s.1).abs() <= eps)) + else { + push_seg(&mut out, &mut pen, seg); + continue; + }; + // The parts of the segment between the points where the area across starts or stops + // (more than `eps` from its ends), each kept unless the area across holds the point + // `eps` past the side beside it: the square across holds band area there exactly when + // its own edge of the band lies farther out than `eps`, and so shows. + let (la, lb) = (split(*upright, a).1, split(*upright, b).1); + let mut ts: Vec = cuts + .iter() + .filter(|&&c| (c - la) * (c - lb) < 0.0 && (c - la).abs() > eps && (c - lb).abs() > eps) + .map(|&c| (c - la) / (lb - la)) + .collect(); + ts.sort_by(f64::total_cmp); + let whole = ts.is_empty(); + ts.insert(0, 0.0); + ts.push(1.0); + for w in ts.windows(2) { + let (p, q) = (a.lerp(b, w[0]), a.lerp(b, w[1])); + let m = if whole { mid } else { p.midpoint(q) }; + let past = at + away * eps; + let probe = if *upright { Point::new(past, m.y) } else { Point::new(m.x, past) }; + if vectorcraft_geom::Shape::winding(area, probe) != 0 { + pen = None; + } else if whole { + push_seg(&mut out, &mut pen, seg); + } else { + push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q))); + } + } + } + out + } +} + +/// Add `seg` to `out`, starting a new subpath unless it starts at `pen`, where the last one ended. +fn push_seg(out: &mut BezPath, pen: &mut Option, seg: PathSeg) { + let a = seg.start(); + if *pen != Some(a) { + out.move_to(a); + } + match seg { + PathSeg::Line(l) => out.line_to(l.p1), + PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } + *pen = Some(seg.end()); +} + +/// How far `q` is from the segment from `a` to `b`. +fn distance_to_segment(q: Point, a: Point, b: Point) -> f64 { + let d = b - a; + let t = if d.hypot2() > 0.0 { ((q - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + q.distance(a + d * t) +} + +/// How far the segment from `a` to `b` is from the segment from `c` to `d`. +fn segment_distance(a: Point, b: Point, c: Point, d: Point) -> f64 { + let side = |p: Point, q: Point, s: Point| (q - p).cross(s - p); + if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 { + return 0.0; + } + distance_to_segment(a, c, d).min(distance_to_segment(b, c, d)).min(distance_to_segment(c, a, b)).min(distance_to_segment(d, a, b)) +} + +/// The stretch of `line`, as `line_t` from and to (0 at its start, 1 at its end, and beyond), +/// within `rad` of the segment from `a` to `b`; `None` when no point of the line is. +fn capsule_span(line: vectorcraft_geom::Line, a: Point, b: Point, rad: f64) -> Option<(f64, f64)> { + let (p, d) = (line.p0, line.p1 - line.p0); + let dd = d.hypot2(); + if dd <= 0.0 || rad.is_nan() || rad < 0.0 { + return None; + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + // The round ends. + for c in [a, b] { + let f = p - c; + let (half_b, k) = (d.dot(f), f.hypot2() - rad * rad); + let disc = half_b * half_b - dd * k; + if disc >= 0.0 { + let root = disc.sqrt(); + lo = lo.min((-half_b - root) / dd); + hi = hi.max((-half_b + root) / dd); + } + } + // The part between them: along the segment from 0 to its length, and across it within `rad`. + let e = b - a; + let len = e.hypot(); + if len > 0.0 { + let u = e / len; + let n = Vec2::new(-u.y, u.x); + let (mut s0, mut s1) = (f64::NEG_INFINITY, f64::INFINITY); + for (at, rate, min, max) in [((p - a).dot(u), d.dot(u), 0.0, len), ((p - a).dot(n), d.dot(n), -rad, rad)] { + if rate == 0.0 { + if at < min || at > max { + s1 = f64::NEG_INFINITY; + } + } else { + let (x, y) = ((min - at) / rate, (max - at) / rate); + s0 = s0.max(x.min(y)); + s1 = s1.min(x.max(y)); + } + } + if s0 <= s1 { + lo = lo.min(s0); + hi = hi.max(s1); + } + } + (lo <= hi).then_some((lo, hi)) +} + +/// `spans` sorted and joined where they overlap. +fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.sort_by(|x, y| x.0.total_cmp(&y.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Whether one of `spans` (from [`merge_spans`]) holds everything from `from` to `to`. +fn spans_cover(spans: &[(f64, f64)], from: f64, to: f64) -> bool { + let i = spans.partition_point(|s| s.0 <= from); + i > 0 && spans.get(i - 1).is_some_and(|s| s.1 >= to) } /// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's @@ -214,6 +822,8 @@ impl GestureTool { snap: DrawSnap::default(), pressed: Point::ZERO, tip_at: None, + band: Cell::new(None), + band_pending: Cell::new(false), } } @@ -349,6 +959,56 @@ impl GestureTool { self.poses.clear(); self.previewed = 0; self.shift_line = None; + self.band.set(None); + self.band_pending.set(false); + } + + /// The outline of the band a smoothing drag smooths: every point within `radius` of the drag, + /// round at both ends. The band shown last is kept and grown by the steps dragged since, at + /// most [`BAND_STEP`] points at a time and [`BAND_STEPS_PER_CALL`] steps a call; a new radius + /// (the zoom changed) or another drag starts it again. While the band covers less of the drag + /// than it can, [`Tool::overlays_pending`] holds, so the host draws again and the band reaches + /// the whole drag with the pointer still. When a step cannot be added whole, its segments are + /// added one at a time: a segment longer than [`BAND_TILE_SAMPLES`] half squares is kept + /// outside the squares, and one whose outlines cannot be combined adds nothing. Either way the + /// band goes on past it. The work on such long segments stops for the call once + /// [`BAND_WORK_PER_CALL`] is spent, and the drag is added to the band only once the outline of + /// what it holds is finished. + fn smooth_band(&self, radius: f64) -> Option { + let pts = &self.points; + let &first = pts.first()?; + let mut b = self + .band + .take() + .filter(|b| b.radius == radius && b.first == first && b.used.checked_sub(1).and_then(|i| pts.get(i)) == Some(&b.last)) + .unwrap_or_else(|| SmoothBand::new(first, radius)); + b.work = 0; + let mut outline = b.outline(); + if !b.changed { + for _ in 0..BAND_STEPS_PER_CALL { + let Some(from) = b.used.checked_sub(1).filter(|_| b.used < pts.len() && b.work < BAND_WORK_PER_CALL) else { break }; + let mut to = (from + BAND_STEP).min(pts.len() - 1); + let Some(piece) = pts.get(from..=to) else { break }; + if b.grow(piece).is_none() { + for (k, w) in piece.windows(2).enumerate() { + if b.work >= BAND_WORK_PER_CALL { + to = from + k; + break; + } + if b.too_long(w[0], w[1]) { + b.add_long(w[0], w[1]); + } else if b.grow(w).is_none() { + b.last = w[1]; + } + } + } + b.used = to + 1; + } + outline = b.outline(); + } + self.band_pending.set(b.used < pts.len() || b.changed); + self.band.set(Some(b)); + outline.filter(|o| !o.elements().is_empty()) } /// Does the tool's Size take Fixed or Pressure (the Blob Brush and the Eraser)? @@ -726,6 +1386,11 @@ impl Tool for GestureTool { fn wants_samples(&self) -> bool { matches!(self.id, "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser") } + /// A smoothing drag's band outlined again after a zoom change reaches the whole drag over + /// several frames. + fn overlays_pending(&self) -> bool { + self.band_pending.get() + } /// `[` and `]` are the Blob Brush's and the Eraser's: their size (#1661). fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) && matches!(self.id, "blobBrush" | "eraser") @@ -776,12 +1441,21 @@ impl Tool for GestureTool { if began { stale.into_iter().chain([Action::Commit]).collect() } else { vec![] } } fn overlays(&self, cx: &ToolContext) -> Vec { + self.band_pending.set(false); let mut o = self.snap.guides().to_vec(); // A path being drawn shows itself, except with the Pencil's Live preview off. if !self.active || self.points.len() < 2 || (self.began && self.previews()) { o.extend(self.tip_outline(cx)); return o; } + // A smoothing drag with a selected path it can smooth also shows, dashed, the band its + // release smooths. + if (self.id == "smooth" || self.smoothing) + && has_selected_smooth_path(cx) + && let Some(path) = self.smooth_band(cx.tol(SMOOTH_RADIUS)) + { + o.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: true }); + } // A Blob Brush drag that smooths shows the Smooth tool's thin line. let width = match self.id { "blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32, diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index f367040611..624adc2e98 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -1037,6 +1037,439 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() { } } +/// The dashed paths among the overlays. +fn bands(t: &GestureTool, cx: &ToolContext) -> Vec { + t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Path { path, dashed: true, .. } = o { Some(path) } else { None }).collect() +} + +/// How far `q` is from the polyline through `pts`. +fn dist_to_drag(q: Point, pts: &[Point]) -> f64 { + pts.windows(2) + .map(|w| { + let ab = w[1] - w[0]; + let t = if ab.hypot2() > 0.0 { ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + q.distance(w[0] + ab * t) + }) + .fold(f64::INFINITY, f64::min) +} + +/// Check the band a smoothing drag through the tool's points shows at radius `r`: one dashed path, +/// every point of which is `r` from the drag, around an area that holds every point nearer than `r` +/// and none farther. Returns the path. +fn check_band(t: &GestureTool, cx: &ToolContext, r: f64) -> vectorcraft_geom::BezPath { + use vectorcraft_geom::kurbo::Shape; + let pts = &t.points; + let found = bands(t, cx); + let [outline] = found.as_slice() else { panic!("one band, got {found:?}") }; + for seg in outline.segments() { + for s in [0.0, 0.25, 0.5, 0.75, 1.0] { + let q = seg.eval(s); + let dq = dist_to_drag(q, pts); + assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}"); + } + } + let kept = t.band.take(); + let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect(); + t.band.set(kept); + let b = outline.bounding_box(); + let mut probes = 0; + for i in 0..=80 { + for j in 0..=80 { + let q = Point::new(b.x0 - r + (b.width() + 2.0 * r) * f64::from(i) / 80.0, b.y0 - r + (b.height() + 2.0 * r) * f64::from(j) / 80.0); + let dq = dist_to_drag(q, pts); + let inside = areas.iter().any(|a| a.contains(q)); + if dq < 0.97 * r { + assert!(inside, "{q:?} {dq} from the drag is inside the band, radius {r}"); + probes += 1; + } else if dq > 1.03 * r { + assert!(!inside, "{q:?} {dq} from the drag is outside the band, radius {r}"); + } + } + } + assert!(probes > 100, "{probes}"); + outline.clone() +} + +/// While a smoothing drag has a selected path to smooth, it shows dashed the band its release smooths: +/// everything within the radius it sends of the drag, round at both ends, around the drag's thin +/// line. Alt smoothing with the Pencil, Paintbrush and Blob Brush shows it too; a drag with nothing +/// selected and a Blob Brush stroke that paints do not. +#[test] +fn smoothing_drag_shows_the_band_it_smooths() { + use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape}; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let none = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let pts = [(50.0, 150.0), (150.0, 150.0)]; + for zoom in [1.0, 2.0, 0.5, 200.0] { + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + cx.zoom = zoom; + let r = match drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()).last().and_then(|a| a.first()) { + Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap_or(0.0), + a => panic!("expected path.smoothRegion, got: {a:?}"), + }; + for (id, alt_smooth, m) in [("smooth", false, Mods::default()), ("pencil", true, alt), ("paintbrush", false, alt), ("blobBrush", false, alt)] + { + let mut t = GestureTool::new(id); + t.set_option("altSmooth", &json!(alt_smooth)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(m)); + let band = check_band(&t, &cx, r); + let b = band.bounding_box(); + let near = |a: f64, w: f64| (a - w).abs() < 0.02 * r; + assert!(near(b.x0, 50.0 - r) && near(b.x1, 150.0 + r) && near(b.y0, 150.0 - r) && near(b.y1, 150.0 + r), "{id} at zoom {zoom}: {b:?}"); + // The whole outline shows: two sides and two half circles. + let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum(); + let want = 200.0 + 2.0 * std::f64::consts::PI * r; + assert!((length - want).abs() < 0.02 * want, "{id} at zoom {zoom}: outline {length}, band {want}"); + assert!( + t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, dashed: false, .. } if *width == 1.0)), + "{id}: the drag's line" + ); + } + // Nothing selected: the release smooths nothing, so no band shows. + let mut empty = crate::testutil::cx(&d, &none, &p); + empty.zoom = zoom; + let mut t = GestureTool::new("smooth"); + t.pointer(&empty, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); + t.pointer(&empty, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1)); + assert!(bands(&t, &empty).is_empty()); + // A Blob Brush stroke that paints shows its own area, not the band. + let mut t = GestureTool::new("blobBrush"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1)); + assert!(bands(&t, &cx).is_empty()); + } +} + +/// The band shown while a long drag goes on, grown step by step, is one outline that holds every +/// point within the radius of the drag and none farther, whether the drag crosses itself or runs +/// on across the page; a zoom change mid-drag with the pointer still asks for frames until the band +/// covers the whole drag at the new radius, and the next drag does not keep the last one's band. +#[test] +fn smoothing_band_follows_a_long_drag() { + use vectorcraft_geom::kurbo::Shape; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + // A curve that crosses itself, and one that runs on across the page. + let crossing: Vec = + (0..240).map(|i| f64::from(i) * 0.05).map(|a| Point::new(250.0 + 80.0 * (a * 1.3).sin(), 250.0 + 60.0 * (a * 0.7).cos())).collect(); + let across: Vec = + (0..900).map(f64::from).map(|i| Point::new(20.0 + 0.5 * i, 250.0 + 100.0 * (i * 0.05).sin() + 30.0 * (i * 0.37).sin())).collect(); + for pts in [crossing, across] { + cx.zoom = 1.0; + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y)); + for q in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y)); + assert_eq!(bands(&t, &cx).len(), 1); + } + check_band(&t, &cx, cx.tol(12.0)); + assert!(!t.overlays_pending(), "the band covers the drag"); + // The view zooms in mid-drag while the pointer holds still: the tool asks for frames until + // the band covers the whole drag at the new radius. + cx.zoom = 2.0; + let mut frames = 1; + bands(&t, &cx); + while t.overlays_pending() { + assert!(frames <= pts.len(), "the band reaches the whole drag"); + bands(&t, &cx); + frames += 1; + } + let n = t.points.len(); + assert_eq!(frames, (n - 1).div_ceil(BAND_STEP * BAND_STEPS_PER_CALL), "frames for {n} points"); + check_band(&t, &cx, cx.tol(12.0)); + assert!(!t.overlays_pending()); + let last = pts[pts.len() - 1]; + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, last.x, last.y)); + // The next drag shows only its own band. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 420.0, 450.0)); + let b = check_band(&t, &cx, cx.tol(12.0)).bounding_box(); + assert!(b.x0 > 390.0 && b.y0 > 440.0, "{b:?}"); + } +} + +/// A segment of a drag too long for the squares at the current zoom (a jump drawn zoomed out, +/// viewed zoomed in, or one reaching far off the page) shows as the two sides of its band, and the +/// band goes on past it: the parts dragged before and after it show, and nothing stays pending. +/// Two such segments that cross show no edge inside each other's band, and one that runs back +/// along another shows its sides once. +#[test] +fn smoothing_band_shows_a_segment_too_long_for_its_squares() { + use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape}; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let before = [Point::new(100.0, 100.0), Point::new(130.0, 100.0)]; + let after = [Point::new(20_100.0, 100.0), Point::new(20_100.0, 130.0), Point::new(20_130.0, 130.0)]; + let far = [Point::new(1e17, 100.0)]; + let crossing = [(100.0, 100.0), (30_100.0, 100.0), (30_100.0, 130.0), (15_100.0, -20_000.0), (15_130.0, -20_000.0)].map(Point::from); + let back = [(100.0, 100.0), (30_100.0, 100.0), (130.0, 100.0)].map(Point::from); + for (case, pts) in [ + ("jump", [&before[..], &after[..]].concat()), + ("far", [&before[..], &far[..], &after[..]].concat()), + ("crossing", crossing.to_vec()), + ("back", back.to_vec()), + ] { + // Drawn zoomed out, where the jump is about 42 half squares, then viewed at 1,000%, where + // it is more than 4,096. + cx.zoom = 0.1; + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y)); + for q in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y)); + bands(&t, &cx); + } + cx.zoom = 10.0; + let r = cx.tol(12.0); + let mut frames = 0; + let mut found = bands(&t, &cx); + while t.overlays_pending() { + assert!(frames < pts.len(), "{case}: the band reaches the whole drag"); + found = bands(&t, &cx); + frames += 1; + } + let [outline] = found.as_slice() else { panic!("{case}: one band, got {found:?}") }; + for seg in outline.segments() { + for s in [0.0, 0.25, 0.5, 0.75, 1.0] { + let q = seg.eval(s); + let dq = dist_to_drag(q, &pts); + assert!((dq - r).abs() < 0.02 * r, "{case}: {q:?} on the outline is {dq} from the drag, radius {r}"); + } + } + // The band of each long segment shows on both of its sides, as far as the release reaches, + // except where the band of another part of the drag holds the side (on the page: a point + // on a segment to 1e17 resolves only to about 10 units). + let on_outline = |q: Point| { + outline.segments().any(|seg| { + (0..=8).any(|k| seg.eval(f64::from(k) / 8.0).distance(q) < 0.02 * r) || dist_to_drag(q, &[seg.start(), seg.end()]) < 0.02 * r + }) + }; + for w in pts.windows(2).filter(|w| w[0].distance(w[1]) > 10_000.0 && w[0].x < 1e6 && w[1].x < 1e6) { + let d = w[1] - w[0]; + let n = vectorcraft_geom::kurbo::Vec2::new(-d.y, d.x) * (r / d.hypot()); + for k in 1..40 { + for q in [w[0].lerp(w[1], f64::from(k) / 40.0) + n, w[0].lerp(w[1], f64::from(k) / 40.0) - n] { + if dist_to_drag(q, &pts) > 0.98 * r { + assert!(on_outline(q), "{case}: {q:?} on a side of {w:?} shows"); + } + } + } + } + if case == "back" { + // Running back along the jump draws its sides once: two sides and two half circles. + let length: f64 = outline.segments().map(|s| s.arclen(1e-6 * r)).sum(); + let want = 2.0 * 30_000.0 + 2.0 * std::f64::consts::PI * r; + assert!((length - want).abs() < 0.01 * want, "{case}: outline {length}, band {want}"); + } + let kept = t.band.take(); + let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect(); + t.band.set(kept); + // Every point near each short part dragged on either side of a long segment is in the band. + for w in pts.windows(2).filter(|w| w[0].distance(w[1]) < 1_000.0) { + for q in (0..=20).map(|k| w[0].lerp(w[1], f64::from(k) / 20.0)) { + for (dx, dy) in [(0.0, 0.0), (0.9, 0.0), (-0.9, 0.0), (0.0, 0.9), (0.0, -0.9)] { + let probe = q + vectorcraft_geom::kurbo::Vec2::new(dx * r, dy * r); + assert!(areas.iter().any(|a| a.contains(probe)), "{case}: {probe:?} near the drag is in the band"); + } + } + } + } +} + +/// A drag drawn zoomed out whose every segment is too long for the squares once the view zooms in, +/// and whose segments all cross one another, is outlined over several calls: no call does more +/// than about [`BAND_WORK_PER_CALL`] of work on the long segments, and the band then shows in full. +#[test] +fn smoothing_band_of_many_long_segments_is_made_in_bounded_calls() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + // A star whose chords nearly all pass through its middle, 4,000 units each. + let step = std::f64::consts::PI * (1.0 - 1.0 / 40.3); + let pts: Vec = (0..161).map(|k| f64::from(k) * step).map(|a| Point::new(5000.0 + 2000.0 * a.cos(), 5000.0 + 2000.0 * a.sin())).collect(); + cx.zoom = 0.05; + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y)); + for q in &pts[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y)); + bands(&t, &cx); + } + cx.zoom = 640.0; + let r = cx.tol(SMOOTH_RADIUS); + let work = |t: &GestureTool| { + let kept = t.band.take(); + let w = kept.as_ref().map_or(0, |b| b.work); + t.band.set(kept); + w + }; + let mut found = bands(&t, &cx); + let mut frames = 1; + let mut most = work(&t); + while t.overlays_pending() { + assert!(frames < 10_000, "the band reaches the whole drag"); + found = bands(&t, &cx); + most = most.max(work(&t)); + frames += 1; + } + assert!(most <= 2 * BAND_WORK_PER_CALL, "{most} work in one call"); + assert!(frames > 2, "{frames} calls"); + let kept = t.band.take(); + let long = kept.as_ref().map_or(0, |b| b.long.len()); + t.band.set(kept); + assert_eq!(long, pts.len() - 1, "every segment is long"); + let [outline] = found.as_slice() else { panic!("one band, got {}", found.len()) }; + for seg in outline.segments().step_by(7) { + for s in [0.0, 0.5, 1.0] { + let q = seg.eval(s); + let dq = dist_to_drag(q, &pts); + assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}"); + } + } +} + +/// The band is kept in squares; a straight drag whose band edge runs along a side of a square, or +/// just inside or outside it, still shows that edge, and the sides between two squares the band +/// fills do not show. +#[test] +fn smoothing_band_shows_an_edge_along_a_square_side() { + use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape}; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + for zoom in [1.0, 3.0] { + cx.zoom = zoom; + let r = cx.tol(SMOOTH_RADIUS); + let side = BAND_TILE_RADII * r; + // A side of the squares near 230 (across and down: the grid is square). + let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side; + let eps = 1e-3 * r; + for off in [0.0, 0.5 * eps, -0.5 * eps, 1.5 * eps, -1.5 * eps] { + for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] { + let c = at + edge + off; + let ends = if upright { + [Point::new(c, 100.0), Point::new(c, 100.0 + 2.5 * side)] + } else { + [Point::new(100.0, c), Point::new(100.0 + 2.5 * side, c)] + }; + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, ends[0].x, ends[0].y)); + for i in 1..=20 { + let q = ends[0].lerp(ends[1], f64::from(i) / 20.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y)); + } + let band = check_band(&t, &cx, r); + // Two sides and two half circles, each shown once. + let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum(); + let want = 2.0 * 2.5 * side + 2.0 * std::f64::consts::PI * r; + assert!( + (length - want).abs() < 0.02 * want, + "zoom {zoom}, edge {edge}, off {off}, upright {upright}: outline {length}, band {want}, {:?}", + band.bounding_box() + ); + } + } + } +} + +/// A drag whose band edge runs along a side of a square and then turns across it shows the edge +/// up to the turn and no line where the band goes on across the side. +#[test] +fn smoothing_band_shows_no_seam_where_it_turns_across_a_square_side() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + for zoom in [1.0, 3.0] { + cx.zoom = zoom; + let r = cx.tol(SMOOTH_RADIUS); + let side = BAND_TILE_RADII * r; + let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side; + let eps = 1e-3 * r; + for off in [0.0, 0.5 * eps, -0.5 * eps] { + for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] { + let c = at + edge + off; + // Along the side, then across it. + let turn = 100.0 + 1.5 * side; + let corners = [(100.0, c), (turn, c), (turn, c - edge.signum() * 2.0 * side)]; + let pts: Vec = corners.into_iter().map(|(u, v)| if upright { Point::new(v, u) } else { Point::new(u, v) }).collect(); + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y)); + for w in pts.windows(2) { + for i in 1..=12 { + let q = w[0].lerp(w[1], f64::from(i) / 12.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y)); + } + } + check_band(&t, &cx, r); + } + } + } +} + +/// One straight piece of a square's outline along a side, where the band goes on across only part +/// of it, shows only the part with nothing across. +#[test] +fn smoothing_band_square_shows_only_the_open_part_of_a_side() { + use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape}; + let mut band = SmoothBand::new(Point::ZERO, 10.0); + let r = band.tile_rect((0, 0)); + let (x, y) = (r.x0, r.y1); + let area = |x0: f64, y0: f64, x1: f64, y1: f64| vectorcraft_geom::shapes::rectangle(Rect::new(x0, y0, x1, y1)); + let tile = |area| BandTile { area, outline: vectorcraft_geom::BezPath::new(), near_long: vec![], cut: vectorcraft_geom::BezPath::new() }; + // Along the bottom side from 10 to 60, with the band across it from 40 to 60. + band.tiles.insert((0, 0), tile(area(x + 10.0, y - 20.0, x + 60.0, y))); + band.tiles.insert((0, 1), tile(area(x + 40.0, y, x + 60.0, y + 20.0))); + let outline = band.tile_outline((0, 0)); + let along: f64 = outline.segments().filter(|s| s.bounding_box().height() < 1e-9 && (s.start().y - y).abs() < 1e-9).map(|s| s.arclen(1e-9)).sum(); + assert!((along - 30.0).abs() < 1e-6, "{along} of the side shows: {}", outline.to_svg()); + for s in outline.segments() { + assert!(!((s.start().y - y).abs() < 1e-9 && (s.end().y - y).abs() < 1e-9 && s.start().x.max(s.end().x) > x + 40.0 + 1e-9), "{s:?}"); + } +} + +/// A selection with no path the Smooth tool can change (a two-anchor line) shows no band. +#[test] +fn smoothing_drag_shows_no_band_without_a_path_it_smooths() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("smooth"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 300.0)); + let o = t.overlays(&cx); + assert!(!o.iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "{o:?}"); +} + /// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush. #[test] fn alt_smoothing_uses_the_smooth_tools_fidelity() { @@ -1665,7 +2098,7 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { /// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with /// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the /// outline of the area it covers with the length label, and an Alt drag that smooths shows the -/// Smooth tool's thin line. +/// Smooth tool's thin line and the dashed band it smooths. #[test] fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() { let (d, id) = doc_with_rect(); @@ -1704,7 +2137,7 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt)); let o = t.overlays(&cx); - let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; + let [Overlay::Path { dashed: true, .. }, Overlay::Path { path, width, dashed: false, .. }] = &o[..] else { panic!("{o:?}") }; assert_eq!(*width, 1.0); assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}"); } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee752dd968..17e6f038e9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -136,6 +136,12 @@ pub trait Tool: Send { fn overlays(&self, _cx: &ToolContext) -> Vec { vec![] } + /// Did the last [`Tool::overlays`] leave part of its overlays for later calls (the band of a + /// long smoothing drag, outlined again after a zoom change)? The host then draws again on the + /// next frame, even when the pointer holds still. + fn overlays_pending(&self) -> bool { + false + } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Arrow } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..10698503f2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1264,6 +1264,10 @@ fn selection_visuals( } else { vec![] }; + // The tool shows the rest of its overlays on the next frame. + if app.session.tool_overlays_pending() { + painter.ctx().request_repaint(); + } // Gap Options' Preview shows the gaps it finds. overlays.extend(crate::dialogs::live_paint_gaps::overlays(app)); if app.ui.view.edges && !revolve_edit { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..e4e975df43 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1032,8 +1032,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size. With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer. - With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. - Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). + With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. + Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).