diff --git a/Cargo.lock b/Cargo.lock index 32fcfc62ad..f8dffe6f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6632,6 +6632,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "vectorcraft-brush", "vectorcraft-doc", "vectorcraft-geom 0.24.1", "vectorcraft-testkit", @@ -6824,6 +6825,7 @@ version = "0.24.1" dependencies = [ "craft-docpath", "craft-svg", + "data-url", "flate2", "image", "kurbo 0.13.1", diff --git a/ROADMAP.md b/ROADMAP.md index 283d7d7dff..ec3f6e9c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -125,6 +125,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged. +- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 3–13 h (estimated), headline numbers unchanged. - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. @@ -224,6 +225,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | Graphic Styles: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h). | +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs index a386e2e181..e54fdd3121 100644 --- a/crates/brush/src/stored_art.rs +++ b/crates/brush/src/stored_art.rs @@ -120,6 +120,13 @@ impl StoredArt { doc.number_mounted_art(); } + /// Sets the art on `doc` as [`Document::mounted_art`] with the ids it is stored with, the id + /// counter left as it is: what a save looks through, writing back only the brushes whose art it + /// changed ([`Self::write_back`]). + pub fn mount_as_stored(&self, doc: &mut Document) { + doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect(); + } + /// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids /// included) back into its library entry: the entry at its index when that entry is as it was /// read or reads as the brush as it was apart from its name, else the first entry that reads as diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 95c8ecfb7a..bb397d7eaa 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -596,6 +596,9 @@ struct Walk<'a> { clips: Vec, /// The points those masks hold ([`MAX_MASK_POINTS`]). mask_points: usize, + /// Set while the stroke pieces of a clipping path whose fill paints are drawn: they close, as + /// its fill closes its outline. + closing: bool, runs: Vec, points: usize, clip_tests: u64, @@ -624,6 +627,7 @@ impl<'a> Walk<'a> { tolerance, clips: vec![], mask_points: 0, + closing: false, runs: vec![], points: 0, clip_tests: 0, @@ -827,7 +831,9 @@ impl<'a> Walk<'a> { let mut paint = first.clip_paint(); // A stroke at 0% opacity isn't drawn, so the clipping path is drawn in its fill. let type_stroke = |s: &Node| matches!(&s.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.has_stroke())); - paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s)); + // The strokes of a clipping path whose stroke effects were applied are a group of pieces, + // which effects around it may have put in groups of their own. + paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s) || strokes_in_pieces(s)); if paint.stroke.is_none() && let Some(fill) = &paint.fill { @@ -852,9 +858,16 @@ impl<'a> Walk<'a> { None => {} } // Drawn whole where its stroke paints: a fill closes its outline, and type's characters - // without a stroke are drawn too. - if paint.stroke.is_some() { - self.node(first, false); + // without a stroke are drawn too. Pieces of a clipping path whose fill and stroke effects + // were applied are drawn in its stroke pieces alone, closed where its fill paints. + if let Some(stroke) = &paint.stroke { + if first.extras().clip_pieces { + let closing = std::mem::replace(&mut self.closing, paint.fill.as_ref().is_some_and(fills_in_pieces)); + self.node(stroke, false); + self.closing = closing; + } else { + self.node(first, false); + } } } @@ -874,7 +887,8 @@ impl<'a> Walk<'a> { /// A path drawn in its color: the topmost stroke's that paints, else the topmost fill's that /// paints (visible, with a paint and some opacity). A filled path's every subpath is closed, - /// as its fill closes it, also when its stroke gives the color. + /// as its fill closes it, also when its stroke gives the color, and so is each stroke piece + /// of a clipping path whose fill paints ([`Walk::closing`]). fn shape(&mut self, n: &Node, bp: &BezPath) { if bp.elements().is_empty() { return; @@ -896,7 +910,7 @@ impl<'a> Walk<'a> { (None, None) => return, }; let rgb = self.flat(paint); - self.draw(rgb, bp, fill.is_some()); + self.draw(rgb, bp, fill.is_some() || self.closing); } /// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added. @@ -1146,6 +1160,18 @@ fn top_stroke(n: &Node) -> Option<&StrokeLayer> { }) } +/// Whether a member of `n`, at any depth of its groups, has a stroke that paints ([`top_stroke`]), +/// with it and the groups around it shown and not fully transparent. +fn strokes_in_pieces(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_stroke(c).is_some() || strokes_in_pieces(c))) +} + +/// Whether a member of `n`, at any depth of its groups, has a fill that paints ([`top_fill`]), +/// with it and the groups around it shown and not fully transparent. +fn fills_in_pieces(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_fill(c).is_some() || fills_in_pieces(c))) +} + /// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity. fn top_fill(n: &Node) -> Option<&Paint> { n.appearance.items.iter().rev().find_map(|i| match i { diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 60f7cf5b3d..493e53906c 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -747,6 +747,86 @@ fn a_clipping_path_is_drawn_where_it_paints() { }); let t = text(&plot(&open_doc, &opts(&open_doc))); assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}"); + // A stroke with a geometry effect of its own is drawn once, over the art it clips, in its color. + let moved = doc_with(|d| { + let (blue, red) = (Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0))); + let mut clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(blue, red, 1.0)); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + let params = serde_json::json!({"moveH": 5}); + s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true }); + } + } + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + assert_eq!(plot(&moved, &opts(&moved)).pens, [[0, 0, 0], [255, 0, 0]]); + // So are the stroke's pieces when an effect around them put them in a group of their own. + let grouped = |opacity: f32| { + doc_with(|d| { + // One path for each, carrying only that fill or stroke. + let piece = |d: &mut Document, fill: bool| { + let mut a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + a.items.retain(|i| matches!(i, AppearanceItem::Fill(_)) == fill); + Arc::new(Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), a)) + }; + let (fill, stroke) = (piece(d, true), piece(d, false)); + let mut copies = Node::group(d.alloc_id(), vec![stroke]); + copies.opacity = opacity; + let mut clip = Node::group(d.alloc_id(), vec![fill, Arc::new(copies)]); + clip.extras_mut().clip_pieces = true; + let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let shown = grouped(1.0); + assert_eq!(plot(&shown, &opts(&shown)).pens, [[0, 0, 0], [255, 0, 0]]); + // In a group at 0% opacity they are not drawn, and the clipping path is drawn in its fill. + let faded = grouped(0.0); + assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); + // An open clipping path with a fill is drawn closed also where its stroke's effect moved it. + let open_moved = |fill_opacity: f32| { + doc_with(|d| { + let mut open = BezPath::new(); + open.move_to((20.0, 20.0)); + open.line_to((40.0, 20.0)); + open.line_to((40.0, 40.0)); + let a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + let mut clip = Node::path(d.alloc_id(), PathData::from_bezpath(&open), a); + for item in &mut clip.appearance.items { + match item { + AppearanceItem::Fill(f) => f.opacity = fill_opacity, + AppearanceItem::Stroke(s) => { + let params = serde_json::json!({"moveH": 5}); + s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true }); + } + } + } + let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let filled = open_moved(1.0); + let t = text(&plot(&filled, &opts(&filled))); + assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847,353,1129;PU;SP0;"), "{t}"); + // A fill at 0% opacity does not close it. + let unfilled = open_moved(0.0); + let t = text(&plot(&unfilled, &opts(&unfilled))); + assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847;PU;SP0;"), "{t}"); } /// A symbol `art` and an instance of it at its place. diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 7fd8707a99..3ad4820ce1 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -94,6 +94,50 @@ impl Intent { } } +/// A color management policy (Edit → Color Settings): what opening a document does with a profile +/// it is tagged with that is not the working space of its model. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// The document loses the profile; its color numbers stay as they are. + Off, + /// The document keeps the profile. + #[default] + PreserveEmbedded, + /// The colors are converted from the profile into the working space, which keeps their + /// appearance, and the document loses the profile. + Convert, + /// CMYK only: the document loses the profile; its color numbers stay as they are. + PreserveNumbers, +} + +impl Policy { + /// The RGB policies, in menu order. + pub const RGB: [Policy; 3] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert]; + /// The CMYK policies, in menu order. + pub const CMYK: [Policy; 4] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers]; + /// Parse an id of [`Policy::id`]. + pub fn parse(s: &str) -> Option { + [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers].into_iter().find(|p| p.id() == s) + } + pub fn id(&self) -> &'static str { + match self { + Policy::Off => "off", + Policy::PreserveEmbedded => "preserveEmbedded", + Policy::Convert => "convert", + Policy::PreserveNumbers => "preserveNumbers", + } + } + pub fn label(&self) -> &'static str { + match self { + Policy::Off => "Off", + Policy::PreserveEmbedded => "Preserve Embedded Profiles", + Policy::Convert => "Convert to Working Space", + Policy::PreserveNumbers => "Preserve Numbers", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileKind { @@ -134,11 +178,25 @@ pub struct ColorSettings { pub intent: Intent, /// Black-point compensation. pub bpc: bool, + /// The color management policy for RGB profiles (one of [`Policy::RGB`]). Settings saved + /// without one take the default. + #[serde(default)] + pub rgb_policy: Policy, + /// The color management policy for CMYK profiles. Settings saved without one take the default. + #[serde(default)] + pub cmyk_policy: Policy, } impl Default for ColorSettings { fn default() -> Self { - Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true } + Self { + rgb: SRGB.into(), + cmyk: GENERIC_CMYK.into(), + intent: Intent::RelativeColorimetric, + bpc: true, + rgb_policy: Policy::default(), + cmyk_policy: Policy::default(), + } } } @@ -518,6 +576,41 @@ impl Cms { Color::Rgb { r, g, b } } + /// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s + /// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to + /// Working Space). RGB converts directly from profile to profile, and CMYK between two loaded + /// profiles through ProPhoto RGB ([`IccProfile::to_same_model_of`]), both with this context's + /// black-point compensation; CMYK goes through Lab when either space is built in. Grey and + /// Lab colours stay as they are. + pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color { + match *c { + Color::Rgb { r, g, b } => { + let icc = |s: &RgbSpace| match s { + RgbSpace::Icc(p) => Some(p.clone()), + RgbSpace::Srgb => builtin_rgb_cache(SRGB), + }; + let [r, g, b] = icc(&self.rgb) + .zip(icc(&dest.rgb)) + .and_then(|(s, d)| s.to_rgb_of(&d, &[r, g, b], intent, self.settings.bpc)) + .unwrap_or_else(|| dest.srgb_to_rgb(self.rgb_to_srgb([r, g, b]))); + Color::Rgb { r, g, b } + } + Color::Cmyk { c, m, y, k } => { + let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0)); + let direct = match (&self.cmyk, &dest.cmyk) { + (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent, self.settings.bpc), + _ => None, + }; + let [c, m, y, k] = match direct.as_deref() { + Some(&[c, m, y, k]) => [c, m, y, k], + _ => dest.lab_to_cmyk(self.cmyk_to_lab(inks), intent), + }; + Color::Cmyk { c, m, y, k } + } + Color::Gray { .. } | Color::Lab { .. } => *c, + } + } + /// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours). pub fn gamut_error(&self, c: &Color) -> f32 { match c { @@ -966,8 +1059,11 @@ pub fn working_settings() -> ColorSettings { } /// Replace the working settings (Edit → Color Settings) and make them the active settings (fails, -/// leaving both unchanged, if a profile is unknown). +/// leaving both unchanged, if a profile is unknown or the RGB policy is Preserve Numbers). pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { + if !Policy::RGB.contains(&settings.rgb_policy) { + return Err(CmsError::Unsupported(format!("the RGB policy can't be {}", settings.rgb_policy.id()))); + } let cms = Arc::new(Cms::new(settings)?); *WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone()); install(cms); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 0b09062b84..ffddd6c955 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,6 +34,17 @@ type Xf = Option>; /// generation only lets the cache drop what reloads left behind. type DirectKey = (u64, u64, usize, bool); +/// The transforms of [`IccProfile::to_same_model_of`]: into ProPhoto RGB's primaries with linear +/// tone curves, where black-point compensation is a linear remap, and from there into the +/// destination. +struct SameModel { + into: Arc, + out_of: Arc, + /// The source's and the destination's black as luminance in the linear space (both 0 without + /// compensation). + black: [f32; 2], +} + /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { pub name: String, @@ -49,6 +60,9 @@ pub struct IccProfile { /// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent /// and black-point compensation. direct: Mutex>)>>, + /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as + /// [`Self::direct`] is. + same_model: Mutex>)>>, } /// A transform from one profile into an RGB profile without passing through sRGB: into the @@ -99,6 +113,7 @@ impl IccProfile { to_srgb: Default::default(), from_srgb: Default::default(), direct: Mutex::new(Vec::new()), + same_model: Mutex::new(Vec::new()), }) } @@ -172,6 +187,21 @@ impl IccProfile { Some(dst.map(|x| x.clamp(0.0, 1.0))) } + /// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of + /// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the + /// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values + /// for each, or no transform can be built. + pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> { + let n = self.channels(); + if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 { + return None; + } + let xf = self.to_xf(intent)?; + xf.transform(v, dst).ok()?; + dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0)); + Some(()) + } + /// sRGB → device values. pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option> { let xf = self.inverse_xf(intent)?; @@ -249,6 +279,69 @@ impl IccProfile { Some(Direct { to_linear, to_dest: Some(to_dest), black }) } + /// Device values of this profile → device values of `dest`, a profile of the same model, + /// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB + /// keep their place. With `bpc` (and an intent other than absolute), this profile's black + /// maps onto the destination's black (black-point compensation). `None` when `dest` is of + /// another model or no transform can be built. + pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option> { + if dest.kind != self.kind || v.len() != self.channels() { + return None; + } + let bpc = bpc && intent != Intent::AbsoluteColorimetric; + let generation = profiles_generation(); + let key = (dest.id, generation, intent_index(intent), bpc); + let t = { + let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner()); + cache.retain(|(k, _)| k.1 == generation); + match cache.iter().find(|(k, _)| *k == key) { + Some((_, t)) => t.clone(), + None => { + let t = self.build_same_model(dest, intent, bpc).map(Arc::new); + cache.push((key, t.clone())); + t + } + } + }?; + let mut lin = [0.0f32; 3]; + t.into.transform(v, &mut lin).ok()?; + let [from, to] = t.black; + if from > 0.0 || to > 0.0 { + lin = lin.map(|x| to + (x - from) * (1.0 - to) / (1.0 - from)); + } + let lin = lin.map(|x| x.clamp(0.0, 1.0)); + let mut out = vec![0.0f32; dest.channels()]; + t.out_of.transform(&lin, &mut out).ok()?; + Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) + } + + fn build_same_model(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option { + let mut linear = rgb_space(super::PROPHOTO_RGB)?; + for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] { + *trc = Some(ToneReprCurve::Parametric(vec![1.0])); + } + linear.cicp = None; + let into = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + let out_of = linear.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; + let black = if bpc { + // Each profile's black: the darkest colour it reproduces, found as `build_direct` + // finds it, as luminance in the linear space. + let luminance = |p: &IccProfile, xf: &Arc| -> Option { + let inks = p.from_srgb([0.0; 3], Intent::RelativeColorimetric)?; + let mut lin = [0.0f32; 3]; + xf.transform(&inks, &mut lin).ok()?; + let y = |c: &moxcms::Xyzd| c.y as f32; + let lum = lin[0] * y(&linear.red_colorant) + lin[1] * y(&linear.green_colorant) + lin[2] * y(&linear.blue_colorant); + Some(lum.clamp(0.0, 0.5)) + }; + let back = dest.profile.create_transform_f32(dest.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + [luminance(self, &into)?, luminance(dest, &back)?] + } else { + [0.0; 2] + }; + Some(SameModel { into, out_of, black }) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 7b22163525..95e465b623 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -442,3 +442,39 @@ fn a_document_gets_the_same_context_for_the_same_profiles() { let unknown = for_document(Some("no such profile"), Some(SRGB)); assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); } + +/// The color management policies parse from their ids, Preserve Numbers is a CMYK policy only, and +/// settings saved before the policies existed read with the default policy. +#[test] +fn policies_parse_and_default_in_saved_settings() { + for p in Policy::CMYK { + assert_eq!(Policy::parse(p.id()), Some(p)); + } + assert_eq!(Policy::parse("bogus"), None); + assert!(!Policy::RGB.contains(&Policy::PreserveNumbers)); + let old: ColorSettings = + serde_json::from_str(r#"{"rgb": "sRGB IEC61966-2.1", "cmyk": "Device CMYK (uncalibrated)", "intent": "perceptual", "bpc": false}"#).unwrap(); + assert_eq!((old.rgb_policy, old.cmyk_policy), (Policy::PreserveEmbedded, Policy::PreserveEmbedded)); + let saved = serde_json::to_value(ColorSettings { cmyk_policy: Policy::PreserveNumbers, ..Default::default() }).unwrap(); + assert_eq!((saved["rgbPolicy"].as_str(), saved["cmykPolicy"].as_str()), (Some("preserveEmbedded"), Some("preserveNumbers"))); +} + +/// Convert to Working Space keeps a color's appearance: an RGB color of a wide space converts into +/// sRGB, and a CMYK color of one CMYK space into another, with numbers that look the same. Grey and +/// Lab colors stay as they are. +#[test] +fn convert_into_keeps_the_appearance() { + let wide = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap(); + let working = cms(); + let rgb = Color::Rgb { r: 0.25, g: 0.4, b: 0.6 }; + let moved = wide.convert_into(&working, &rgb, Intent::RelativeColorimetric); + assert_ne!(moved, rgb, "the numbers change"); + assert!(de(wide.display_rgb(&rgb), working.display_rgb(&moved)) < 1.0, "{rgb:?} → {moved:?}"); + let ink = Color::Cmyk { c: 0.2, m: 0.4, y: 0.6, k: 0.1 }; + let moved = wide.convert_into(&working, &ink, Intent::RelativeColorimetric); + assert!(matches!(moved, Color::Cmyk { .. }) && moved != ink, "{ink:?} → {moved:?}"); + assert!(delta_e2000(wide.lab(&ink), working.lab(&moved)) < 3.0, "{ink:?} → {moved:?}"); + for c in [Color::Gray { k: 0.3 }, Color::Lab { l: 50.0, a: 10.0, b: -20.0 }] { + assert_eq!(wide.convert_into(&working, &c, Intent::RelativeColorimetric), c); + } +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 1d312c09c3..bee0f0f9fa 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -48,6 +48,28 @@ fn gray_is_srgb_toned() { } } +#[test] +fn many_pixels_convert_as_each_one_does() { + let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap(); + let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]]; + for intent in Intent::ALL { + let v: Vec = px.iter().flatten().copied().collect(); + let mut dst = vec![0.0; v.len()]; + p.to_srgb_pixels(&v, &mut dst, intent).unwrap(); + for (i, rgb) in px.iter().enumerate() { + let one = p.to_srgb(rgb, intent).unwrap(); + assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}"); + } + } + // Lengths that aren't whole pixels, or a destination of another size, are refused. + assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none()); + assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none()); + let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap(); + let mut dst = [0.0; 6]; + gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap(); + assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}"); +} + #[test] fn encoded_profiles_are_reproducible() { assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap()); @@ -252,3 +274,55 @@ fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}"); assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}"); } + +/// A CMYK profile converts into another CMYK profile, ink for ink, and refuses a profile +/// of another model or inks of the wrong count. +#[test] +fn cmyk_profiles_convert_into_each_other_directly() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap(); + let inks = [0.2, 0.4, 0.6, 0.1]; + let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric, false).unwrap(); + assert_eq!(out.len(), 4); + assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}"); + assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric, false).is_none()); + assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric, false).is_none()); +} + +/// With black-point compensation, a CMYK profile's darkest ink converts into an ink as dark as a +/// darker profile's darkest ink, and a dark grey of a darker profile converts lighter into a +/// profile whose black is lighter; absolute colorimetric never compensates. +#[test] +fn cmyk_to_cmyk_compensates_the_black_point() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let rel = Intent::RelativeColorimetric; + let lightness = |p: &IccProfile, inks: &[f32]| lab::srgb_to_lab(p.to_srgb(inks, rel).unwrap()).l; + let black = |p: &IccProfile| p.from_srgb([0.0; 3], rel).unwrap(); + // Generic CMYK's black is a dark grey, the uncalibrated profile's is black. + let (light, dark) = (lightness(&generic, &black(&generic)), lightness(&device, &black(&device))); + assert!(light > dark + 5.0, "{light} vs {dark}"); + let into_device = |bpc| lightness(&device, &generic.to_same_model_of(&device, &black(&generic), rel, bpc).unwrap()); + let (on, off) = (into_device(true), into_device(false)); + assert!((on - dark).abs() < 1.5 && off > dark + 5.0, "with {on}, without {off}, the black {dark}"); + let grey = device.from_srgb([0.2; 3], rel).unwrap(); + let into_generic = |bpc| lightness(&generic, &device.to_same_model_of(&generic, &grey, rel, bpc).unwrap()); + let (on, off) = (into_generic(true), into_generic(false)); + assert!(on > off + 1.0, "with {on}, without {off}"); + for (from, to, inks) in [(&generic, &device, black(&generic)), (&device, &generic, grey)] { + let abs = |bpc| from.to_same_model_of(to, &inks, Intent::AbsoluteColorimetric, bpc).unwrap(); + assert_eq!(abs(true), abs(false)); + } + // Convert to Working Space (Cms::convert_into) compensates as Color Settings say. + let device_name = register_icc(&icc_bytes(DEVICE_CMYK).unwrap(), Some("Test ICC Device CMYK".into())).unwrap().name; + let rich = Color::Cmyk { c: 0.9, m: 0.8, y: 0.8, k: 1.0 }; + let convert = |bpc| { + let dest = Cms::new(&ColorSettings { cmyk: device_name.clone(), bpc, ..ColorSettings::default() }).unwrap(); + icc_cmyk(bpc).convert_into(&dest, &rich, rel) + }; + assert_ne!(convert(true), convert(false)); + // White is untouched. + let white = generic.to_same_model_of(&device, &[0.0; 4], rel, true).unwrap(); + assert!(white.iter().all(|v| *v < 0.01), "{white:?}"); +} diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 71c6642846..6339f0a4ca 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -525,8 +525,8 @@ fn planar(d: &mut tiff::decoder::Decoder) - } /// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color -/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file -/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// type has), pixel by pixel and 8 bits each (16-bit samples rounded to the nearest), whether the +/// file stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MiB of samples), and, before any decoding, files with more than /// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, files whose samples aren't unsigned integers (a @@ -535,7 +535,7 @@ fn planar(d: &mut tiff::decoder::Decoder) - pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { Some(match read_samples_at_depth(d, n, per)? { Samples::Eight(v) => v, - Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + Samples::Sixteen(v) => v.iter().map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)).collect(), }) } @@ -618,13 +618,25 @@ pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> { icc.get(16..20)?.try_into().ok() } -/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one. +/// The most bytes of an ICC profile a TIFF's tag 34675 is read with (16 MiB). +const MAX_TIFF_ICC: u64 = 16 << 20; + +/// The ICC profile of TIFF tag 34675 (ICCProfile, of type UNDEFINED or BYTE) of the image `d` +/// reads, as its bytes; `None` without one and for one over [`MAX_TIFF_ICC`] bytes. fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> { - d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty()) + use tiff::tags::{Tag, Type, ValueBuffer}; + let mut ifd = d.image_ifd(); + let entry = ifd.find_entry(Tag::IccProfile)?; + if !matches!(entry.field_type(), Type::UNDEFINED | Type::BYTE) || entry.count() == 0 || entry.count() > MAX_TIFF_ICC { + return None; + } + let mut buf = ValueBuffer::empty(Type::UNDEFINED); + ifd.find_tag_buf(Tag::IccProfile, &mut buf).ok()??; + Some(buf.as_bytes().to_vec()) } -/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own -/// limits, whatever the size of the image; `None` for other images and TIFFs without one. +/// The ICC profile of TIFF `bytes`' first image (tag 34675), up to [`MAX_TIFF_ICC`] bytes whatever +/// the size of the image; `None` for other images, TIFFs without one and a longer one. pub fn tiff_icc_profile(bytes: &[u8]) -> Option> { let (_, mut d) = tiff(bytes)?; tiff_icc(&mut d) @@ -638,6 +650,61 @@ fn jpeg_icc(bytes: &[u8]) -> Option> { d.icc_profile().filter(|icc| !icc.is_empty()) } +/// The bytes after an `ICC_PROFILE` APP2 segment's length: its name, then the segment's number and +/// the count of segments. +const ICC_SEGMENT: &[u8; 12] = b"ICC_PROFILE\0"; + +/// JPEG `bytes` carrying `icc` in place of the ICC profile they carry, if any: every +/// `ICC_PROFILE` APP2 segment before the scan taken out, `icc` written as numbered APP2 segments +/// after the leading application segments, and the other segments and the scan as they are. `None` +/// for bytes whose segments before the scan can't be read, an empty `icc`, and one longer than 255 +/// segments hold. +pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option> { + // A segment's length counts itself (2 bytes) and the name, number and count (14). + const CHUNK: usize = 0xFFFF - 2 - ICC_SEGMENT.len() - 2; + let chunks: Vec<&[u8]> = icc.chunks(CHUNK).collect(); + let count = u8::try_from(chunks.len()).ok().filter(|n| *n > 0)?; + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut segments = Vec::with_capacity(icc.len() + 18 * chunks.len()); + for (i, chunk) in chunks.iter().enumerate() { + let len = u16::try_from(chunk.len() + 2 + ICC_SEGMENT.len() + 2).ok()?; + segments.extend_from_slice(&[0xFF, 0xE2]); + segments.extend_from_slice(&len.to_be_bytes()); + segments.extend_from_slice(ICC_SEGMENT); + segments.extend_from_slice(&[u8::try_from(i + 1).ok()?, count]); + segments.extend_from_slice(chunk); + } + let mut out = Vec::with_capacity(bytes.len() + segments.len()); + out.extend_from_slice(&[0xFF, 0xD8]); + let mut icc_segments = Some(segments); + let mut at = 2; + // The segments before the scan (SOS), each with its length; a marker without one ends them. + // Fill bytes (0xFF) before a marker are left out. + loop { + while bytes.get(at..at + 2) == Some(&[0xFF, 0xFF]) { + at += 1; + } + let (Some(&0xFF), Some(&marker)) = (bytes.get(at), bytes.get(at + 1)) else { return None }; + let app = (0xE0..=0xEF).contains(&marker); + if !app && let Some(s) = icc_segments.take() { + out.extend_from_slice(&s); + } + if matches!(marker, 0xDA | 0xD8 | 0xD9 | 0x01 | 0xD0..=0xD7) { + break; + } + let len = usize::from(u16::from_be_bytes(bytes.get(at + 2..at + 4)?.try_into().ok()?)); + let segment = bytes.get(at..(at + 2).checked_add(len)?).filter(|_| len >= 2)?; + if !(marker == 0xE2 && segment.get(4..16) == Some(ICC_SEGMENT.as_slice())) { + out.extend_from_slice(segment); + } + at += segment.len(); + } + out.extend_from_slice(bytes.get(at..)?); + Some(out) +} + /// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in. fn cmyk_icc(icc: Option>) -> Option> { icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")) @@ -746,6 +813,14 @@ impl ImageBlob { Some(Self::new("image/tiff", tiff)) } + /// [`ImageBlob::cmyk_tiff`], carrying `icc` as tag 34675 (ICCProfile) when it is a profile of + /// CMYK colors; any other profile is left out. The caller checks the rest of the profile. + pub fn cmyk_tiff_with_icc(inks: &Inks, icc: Option<&[u8]>) -> Option { + let icc = icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")); + let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), None, icc)?; + Some(Self::new("image/tiff", tiff)) + } + /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its /// header. pub fn is_cmyk(&self) -> bool { @@ -1968,6 +2043,35 @@ mod tests { assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]); } + /// A CMYK TIFF written with a CMYK profile carries it byte for byte as tag 34675; a profile of + /// other colors, and none, leave the TIFF untagged. The inks are the same either way. + #[test] + fn a_cmyk_tiff_carries_the_cmyk_profile_it_is_given() { + let profile = |space: &[u8; 4]| { + let mut icc = vec![0u8; 128]; + icc[0..4].copy_from_slice(&128u32.to_be_bytes()); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc + }; + let cmyk = profile(b"CMYK"); + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&cmyk)).unwrap(); + assert_eq!(b.cmyk_icc_profile(), Some(cmyk), "the profile, byte for byte"); + assert_eq!(b.cmyk(), Some(inks())); + // Read back up to 16 MiB; a longer one isn't read. + for (len, read) in [(16 << 20, true), ((16 << 20) + 1, false)] { + let mut big = profile(b"CMYK"); + big.resize(len, 0); + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&big)).unwrap(); + assert_eq!(b.cmyk_icc_profile().is_some_and(|p| p == big), read, "{len} bytes"); + } + for icc in [Some(profile(b"RGB ")), None] { + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), icc.as_deref()).unwrap(); + assert_eq!(tiff_icc_profile(&b.bytes), None, "{icc:?}"); + assert_eq!(b.cmyk(), Some(inks())); + } + } + #[test] fn a_header_gives_the_size_without_the_samples() { assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2))); @@ -2031,6 +2135,53 @@ mod tests { } } + /// A CMYK JPEG given a profile carries it in place of its own, its samples as they were; a + /// profile longer than 255 segments hold, an empty one and bytes that aren't a JPEG give none. + #[test] + fn a_cmyk_jpeg_carries_the_profile_it_is_given() { + let profile = |len: usize, mark: u8| { + let mut icc = vec![mark; len]; + icc[16..20].copy_from_slice(b"CMYK"); + icc + }; + let px: Vec = (0..16 * 16).flat_map(|_| [10u8, 40, 100, 20]).collect(); + let mut jpg = Vec::new(); + let mut e = jpeg_encoder::Encoder::new(&mut jpg, 100); + e.add_icc_profile(&profile(300_000, 1)).unwrap(); + e.encode(&px, 16, 16, jpeg_encoder::ColorType::Cmyk).unwrap(); + let inks = ImageBlob::new("image/jpeg", jpg.clone()).cmyk().unwrap(); + // One segment holds 65,519 bytes of a profile. + for len in [128, 65_519, 65_520, 300_000, 1 << 20] { + let icc = profile(len, 2); + let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&jpg, &icc).unwrap()); + assert!(b.cmyk_icc_profile() == Some(icc), "{len} bytes"); + assert!(b.cmyk() == Some(inks.clone()), "{len} bytes: the samples as they were"); + } + // A profile segment after the quantization table is taken out too. + let dqt = jpg.windows(2).position(|w| w == [0xFF, 0xDB]).unwrap(); + let after = dqt + 2 + usize::from(u16::from_be_bytes([jpg[dqt + 2], jpg[dqt + 3]])); + let mut late = jpg.clone(); + let stale = profile(128, 3); + let segment = [&[0xFF, 0xE2, 0, 144][..], ICC_SEGMENT, &[1, 1], &stale].concat(); + late.splice(after..after, segment); + let icc = profile(128, 2); + let b = jpeg_with_icc(&late, &icc).unwrap(); + assert_eq!(b.windows(ICC_SEGMENT.len()).filter(|w| *w == ICC_SEGMENT).count(), 1, "one profile segment"); + assert!(ImageBlob::new("image/jpeg", b).cmyk_icc_profile() == Some(icc)); + // Fill bytes before the quantization table's marker are passed over. + let mut filled = jpg.clone(); + filled.splice(dqt..dqt, [0xFF; 3]); + assert!(ImageBlob::new("image/jpeg", filled.clone()).cmyk() == Some(inks.clone()), "the decoder reads it"); + let icc = profile(128, 2); + let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&filled, &icc).unwrap()); + assert!(b.cmyk_icc_profile() == Some(icc), "fill bytes"); + assert!(b.cmyk() == Some(inks.clone()), "fill bytes: the samples as they were"); + assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519, 2)).is_some(), "255 segments"); + assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519 + 1, 2)).is_none(), "256 segments"); + assert!(jpeg_with_icc(&jpg, &[]).is_none(), "empty"); + assert!(jpeg_with_icc(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes, &profile(128, 2)).is_none(), "a TIFF"); + } + #[test] fn other_images_are_not_cmyk() { let mut png = Vec::new(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 5885a0ff54..04b48634ed 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -504,7 +504,7 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab /// [`hit_test`] passing over the nodes `skip` names (and their contents), and with /// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of /// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art -/// ([`Node::geometric_bounds_with`]). +/// ([`Node::geometric_bounds_with`]), and are also hit outside that box where their art paints. fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { let finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); @@ -1055,9 +1055,17 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A symbol instance is hit within its bounds, and outside them where its art paints + // ([`instance_art_hit`]). + NodeKind::SymbolInstance { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + instance_art_hit(n, p, opt, symbols) + } // A blend or an envelope is hit within its bounds, and outside them where its evaluated // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, // and the strokes of an envelope's distorted contents past its bounds. @@ -1211,64 +1219,94 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or /// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { - struct Find<'a> { - art: &'a InstanceArt<'a>, - p: Point, - opt: HitOptions, - symbols: &'a SymbolBoxes<'a>, - } - impl Find<'_> { - fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { - self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) - } - fn skips_guide(&self, n: &Node) -> bool { - self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) - } - /// `outline`: the view is in Outline, or a layer around `n` has Preview off. - fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> bool { - if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { - return false; - } - let outline = outline || previews_off(n); - let opt = HitOptions { outline, ..self.opt }; - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; - if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole, opt); - } - let whole = self.art.whole(n, whole); - if n.clips() - && let Some(first) = children.first() - { - let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; - if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() - { - path.push(0); - return true; - } - if !clip_contains(&clip, self.p, outline) { - return false; - } - } - for (i, c) in children.iter().enumerate().rev() { - // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. - if shaper_hides(n, i, None) { - continue; - } - let Ok(i) = u32::try_from(i) else { continue }; - path.push(i); - if self.find(c, path, whole, outline) { - return true; - } - path.pop(); - } - false - } - } let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; let outline = opt.outline || below_preview_off(doc, inst, opt.scope); - Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path) + let find = FindPart { art: &art, p, opt, symbols: &symbols, whole_instance: false }; + find.find(&art.art, &mut path, None, outline).map(|_| path) +} + +/// The walk of a symbol instance's placed art that [`symbol_part_at`] and [`instance_art_hit`] make. +struct FindPart<'a> { + art: &'a InstanceArt<'a>, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + /// For [`instance_art_hit`]: locked and template objects of the art count, as the canvas draws + /// them, and clipping paths are not picked by their outline. + whole_instance: bool, +} + +impl FindPart<'_> { + fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> Option { + self.art.place(n, whole).and_then(|n| hit_any(&n, self.p, opt, self.symbols)) + } + + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + + /// What a click at `p` hits on object `n` of the art, with `path` the child indices that lead + /// to what it hits. `outline`: the view is in Outline, or a layer around `n` has Preview off. + fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> Option { + if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) { + return None; + } + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole, opt); + } + let whole = self.art.whole(n, whole); + if n.clips() + && let Some(first) = children.first() + { + let clip = self.art.place_unclipped(first, whole)?; + if !self.whole_instance + && self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && let Some(kind) = clip_outline(&clip, self.p, opt) + { + path.push(0); + return Some(kind); + } + if !clip_contains(&clip, self.p, outline) { + return None; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if let Some(kind) = self.find(c, path, whole, outline) { + return Some(kind); + } + path.pop(); + } + None + } +} + +/// What a click at `p` hits of symbol instance `n`'s art where the canvas draws it (`None` in +/// Outline, [`HitOptions::outline`]): its strokes, and the art that the live geometry effects of a +/// clipping path in it show past the art's box. `None` for an instance of a missing symbol, of a +/// symbol that holds an instance of itself or nests too deep ([`SymbolBoxes::art`]), and of art past +/// the 9-slice object limit ([`InstanceArt::of`]). +fn instance_art_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return None }; + if opt.outline || symbols.art(symbol).is_none() { + return None; + } + let art = InstanceArt::of(symbols.document(), n)?; + let opt = HitOptions { clip_outlines: false, ..opt }; + FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false) } /// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks @@ -1435,7 +1473,8 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured -/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a +/// by their art ([`Node::geometric_bounds_with`]), and outside that box, except in outline, they are +/// touched where the live geometry effects of a clipping path in their art show it. The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. In isolation mode the clipping mask of the isolated container, /// or of the parent of the single object isolated, does not limit the marquee, as it does not limit @@ -1573,14 +1612,33 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh } NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)), NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)), - // A box with no width or no height (an instance of a symbol that draws a straight - // line) is touched where the marquee crosses it. - _ => n.geometric_bounds_with(s).is_some_and(|b| { - let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; - b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses - }), + // A symbol instance is touched by its box, and outside it where the live geometry + // effects of a clipping path in its art show the art. + NodeKind::SymbolInstance { .. } => box_touches(n, s, r) || !outline && instance_art_touches(n, s, r, whole_compounds), + _ => box_touches(n, s, r), } } + /// Does the marquee `r` touch the box of `n`? A box with no width or no height (an instance of + /// a symbol that draws a straight line) is touched where the marquee crosses it. + fn box_touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool { + n.geometric_bounds_with(s).is_some_and(|b| { + let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses + }) + } + /// Does the marquee `r` touch the art of symbol instance `n` placed as the canvas draws it, as + /// it touches a group of that art? Only for art whose clipping paths have live geometry effects + /// ([`SymbolBoxes::art`]), and only when `r` meets the instance's reach + /// ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's box. + fn instance_art_touches(n: &Node, s: &SymbolBoxes, r: Rect, whole_compounds: bool) -> bool { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return false }; + let meets = |b: Rect| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + if s.art(symbol).is_none_or(|a| a.reshaped.is_none()) || !n.reach_bounds_with(s).is_some_and(meets) { + return false; + } + let Some(art) = InstanceArt::of(s.document(), n) else { return false }; + art.place(&art.art, None).is_some_and(|placed| placed.visible && touches(&placed, s, r, None, whole_compounds, false)) + } fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index dde7365424..1c817f2bf1 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1852,6 +1852,115 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined"); } +/// A symbol instance whose art holds a clip group or a clipping layer with a live effect that moves +/// its clipping path is clicked, and touched by a marquee, where the moved region shows the art past +/// the art's box: inside a group, nested in another symbol's art, moved and scaled. In Outline view, +/// below a layer whose Preview is off, where the art is hidden and where no region is left, only the +/// box counts. +#[test] +fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art() { + use vectorcraft_geom::Affine; + set_clip_region(moved_clip_region); + // `effect` on the clipping path of a clip group (a clipping layer with `layer`), whose art is + // shown when `shown`; `xf` places the instance, which with `nest` shows a symbol whose art holds + // an instance of the clipped symbol. The instance is in a group: the group, the instance. + let build = |effect: &str, xf: Affine, nest: bool, shown: bool, layer: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let (mut mask, mut art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() }); + art.visible = shown; + let mut clip = if layer { + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer + } else { + Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]) + }; + clip.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) }); + let mut symbol = "Clipped"; + if nest { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: Affine::IDENTITY }); + let root = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(root) }); + symbol = "Outer"; + } + let (g, inst) = (d.alloc_id(), d.alloc_id()); + let instance = Node::new(inst, NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(instance)])).unwrap(); + (d, g, inst) + }; + let opt = HitOptions::default(); + let outline = HitOptions { outline: true, ..opt }; + let at = |d: &Document, x: f64, y: f64, opt: HitOptions| hit_test(d, Point::new(x, y), opt).map(|h| h.leaf); + // Across the art's top edge in the moved region, and past the art's right end. + let (edge, past) = (Rect::new(140.0, -5.0, 160.0, 5.0), Rect::new(240.0, -5.0, 260.0, 5.0)); + for layer in [false, true] { + let (d, g, inst) = build("test.move", Affine::IDENTITY, false, true, layer); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "in the moved region, layer {layer}"); + assert_eq!(hit_test(&d, Point::new(150.0, 50.0), opt).map(|h| h.top_object(None)), Some(g)); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "in the box"); + assert_eq!(at(&d, 250.0, 50.0, opt), None, "past the art"); + assert_eq!(at(&d, 150.0, 150.0, opt), None, "below the art"); + assert_eq!(at(&d, 150.0, 50.0, outline), None, "Outline view draws the plain clipping path"); + assert_eq!(marquee(&d, edge, None, false), [g], "layer {layer}"); + assert_eq!(marquee(&d, edge, None, true), [inst], "layer {layer}"); + assert!(marquee(&d, past, None, false).is_empty()); + assert!(marquee_in_view(&d, edge, None, false, true).is_empty(), "Outline view"); + } + // Preview off for the layer the instance is on. + let (mut d, _, _) = build("test.move", Affine::IDENTITY, false, true, false); + if let NodeKind::Layer { preview, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *preview = false; + } + assert_eq!(at(&d, 150.0, 50.0, opt), None, "Preview off"); + assert!(marquee(&d, edge, None, false).is_empty(), "Preview off"); + // Moved, scaled (the region moves 100 pt from the placed clipping path) and nested. + let (d, _, inst) = build("test.move", Affine::translate((10.0, 20.0)), false, true, false); + assert_eq!(at(&d, 160.0, 70.0, opt), Some(inst), "moved"); + let (d, _, inst) = build("test.move", Affine::scale(2.0), false, true, false); + assert_eq!(at(&d, 250.0, 100.0, opt), Some(inst), "scaled"); + assert_eq!(at(&d, 350.0, 100.0, opt), None, "scaled, past the region"); + let (d, g, inst) = build("test.move", Affine::IDENTITY, true, true, false); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "nested"); + assert_eq!(marquee(&d, edge, None, false), [g], "nested"); + // Hidden art, and an effect that leaves no region: clicks find only the box. The marquee still + // touches the moved clipping path on the edge of its region, as in a clip group, unless no + // region is left. + for (effect, shown) in [("test.move", false), ("test.empty", true)] { + let (d, g, inst) = build(effect, Affine::IDENTITY, false, shown, false); + assert_eq!(at(&d, 150.0, 50.0, opt), None, "{effect}, shown {shown}"); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "{effect}, shown {shown}: in the box"); + let touched: &[NodeId] = if shown { &[] } else { &[g] }; + assert_eq!(marquee(&d, edge, None, false), touched, "{effect}, shown {shown}"); + } +} + +/// A click on the stroke of a symbol's art where it paints past the instance's box selects the +/// instance; past the stroke, and in Outline view, it does not. +#[test] +fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // A 20 pt stroke reaches 10 pt past the 0..100 square. + let look = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0); + let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look); + let art = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Stroked".into(), art: Arc::new(art) }); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::IDENTITY; + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Stroked".into(), xf })).unwrap(); + let at = |x: f64, opt: HitOptions| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf); + assert_eq!(at(108.0, HitOptions::default()), Some(inst), "on the stroke past the box"); + assert_eq!(at(50.0, HitOptions::default()), Some(inst), "in the box"); + assert_eq!(at(116.0, HitOptions::default()), None, "past the stroke"); + assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index a9fa5f3f80..ed920fbd41 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -80,16 +80,22 @@ pub fn spot_color(doc: &Document, name: &str) -> Option { /// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. pub type Link<'a> = Option<(&'a str, f32)>; +/// An ink amount clamped to 0..1; an amount that is not a finite number prints no ink. +fn ink_amount(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + /// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at -/// its tint; one linked to the Registration swatch prints its tint on every plate. +/// its tint; one linked to the Registration swatch prints its tint on every plate. A tint that is +/// not a finite number prints no ink. pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { if let Some((REGISTRATION, tint)) = link { - return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; + return Inks { cmyk: [ink_amount(tint); 4], spot: None, registration: true }; } if let Some((name, tint)) = link && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) { - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), ink_amount(tint))), registration: false }; } Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } @@ -104,7 +110,7 @@ pub type StopPlates = Option<[f32; 4]>; pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks { let mut ink = inks(doc, c, color, link, intent); if let (Some(p), Some(_)) = (plates, &ink.spot) { - ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + ink.cmyk = p.map(ink_amount); } ink } @@ -227,6 +233,30 @@ mod tests { } } + #[test] + fn a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink() { + let d = gold_doc(); + let c = vectorcraft_color::cms::active(); + let look = Color::cmyk(0.25, 0.1, 0.8, 0.05); + let ink = |link: Link| inks(&d, &c, &look, link, Intent::RelativeColorimetric); + for t in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let spot = ink(Some(("Gold", t))); + assert_eq!(spot, Inks { cmyk: [0.0; 4], spot: Some(("Gold".into(), 0.0)), registration: false }, "{t}"); + assert_eq!((spot.on_plate("Gold"), spot.on_plate("Cyan")), (0.0, 0.0), "{t}"); + let reg = ink(Some((REGISTRATION, t))); + assert_eq!(reg, Inks { cmyk: [0.0; 4], spot: None, registration: true }, "{t}"); + assert_eq!((reg.on_plate("Gold"), reg.on_plate("Black")), (0.0, 0.0), "{t}"); + // A stop's process plates still print beside a spot tint that prints nothing. + let stop = stop_inks(&d, &c, &look, Some(("Gold", t)), Some([0.25, 0.0, 0.4, 0.0]), Intent::RelativeColorimetric); + assert_eq!((stop.cmyk, stop.on_plate("Gold")), ([0.25, 0.0, 0.4, 0.0], 0.0), "{t}"); + } + // Finite tints outside 0..1 are clamped. + assert_eq!(ink(Some(("Gold", 2.0))).on_plate("Gold"), 1.0); + assert_eq!(ink(Some(("Gold", -1.0))).on_plate("Gold"), 0.0); + assert_eq!(ink(Some((REGISTRATION, 2.0))).cmyk, [1.0; 4]); + assert_eq!(ink(Some((REGISTRATION, -1.0))).cmyk, [0.0; 4]); + } + #[test] fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() { let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6b797a371..7b7ef4fee7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; pub use text::GlyphForm; +pub use text::PATH_LEAD_MAX; pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, @@ -127,7 +128,7 @@ pub use text::{ RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; -pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; +pub use text::{CharClass, CompositeClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 20d0623d4d..6b16973cf1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -358,6 +358,34 @@ mod tests { assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } + #[test] + fn coons_places_are_where_the_patch_bends() { + // A rectangle's sides have 65 evenly spaced points. + let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places(); + assert_eq!((u.len(), v.len()), (65, 65)); + assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}"); + // A top object with a curved top: between two places next to each other the patch is + // straight, in `u` and in `v`. + let mut path = kurbo::BezPath::new(); + path.move_to((0.0, 0.0)); + path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0)); + path.line_to((100.0, 50.0)); + path.line_to((40.0, 50.0)); + path.line_to((0.0, 50.0)); + path.close_path(); + let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap(); + let [u, v] = c.places(); + assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}"); + assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + for w in u.windows(2) { + for y in [0.0, 0.3, 1.0] { + let mid = c.eval((w[0] + w[1]) / 2.0, y); + let half = c.eval(w[0], y).midpoint(c.eval(w[1], y)); + assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}"); + } + } + } + #[test] fn maps_go_on_past_the_content_box() { // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index b6d5bbc88d..ef0a9a908d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -338,6 +338,31 @@ impl Coons { let end = |side: &[Point]| side.last().copied().unwrap_or_default(); [self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]] } + + /// The places in `u` where the top or bottom side has a point, and the places in `v` where the + /// left or right side has one, rising from 0 to 1. Between two places next to each other, each + /// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of + /// the grid they make. + pub fn places(&self) -> [Vec; 2] { + let sides = [&self.top, &self.right, &self.bottom, &self.left]; + let at = |which: usize| -> Vec { + match self.knots.as_deref().and_then(|k| k.get(which)) { + Some(knots) => knots.clone(), + None => { + let last = sides[which].len().saturating_sub(1).max(1); + (0..=last).map(|i| i as f64 / last as f64).collect() + } + } + }; + let merge = |a: usize, b: usize| { + let mut v = at(a); + v.extend(at(b)); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + [merge(0, 2), merge(1, 3)] + } } /// The most points one side of [`Coons::from_top_object`] holds while it adds points between its diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 7ff6cdeea2..464e1b3f65 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -105,8 +105,13 @@ impl Document { n += shared(&self.para_styles, &newer.para_styles, styles); n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + let custom = |f: &crate::CompositeFont| { + vec_bytes(&f.custom) + f.custom.iter().map(|s| s.name.capacity() + s.characters.capacity() + part(&s.part)).sum::() + }; vec_bytes(c) - + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + + c.iter() + .map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::() + custom(f)) + .sum::() }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index cb66ba1148..b26fa52e1c 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -74,19 +74,32 @@ impl Node { /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted /// over it (unclipped). Each part is this object keeping only those appearance items (type: /// also only its characters' fills or strokes); `None` where it paints nothing, as right after - /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths, and so does + /// a group of the pieces a clipping path's fills and strokes draw ([`crate::NodeExtras::clip_pieces`]), + /// even after effects around it split or copy those pieces into groups: each part is then that + /// group keeping only the pieces of those fills or strokes. pub fn clip_paint(&self) -> ClipPaint { use crate::appearance::AppearanceItem; + let item = |fill: bool, i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + if matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().clip_pieces { + let part = |fill: bool| { + let mut paints = false; + let mut n = pieces_of(self, fill, &|i| item(fill, i), &mut paints)?; + n.extras_mut().clip_pieces = false; + paints.then_some(n) + }; + return ClipPaint { fill: part(true), stroke: part(false) }; + } let runs = match &self.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, NodeKind::Text(t) => Some(&t.runs), _ => return ClipPaint::default(), }; let paints = |fill: bool| { - let item = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, - }; + let item = |i: &AppearanceItem| item(fill, i); let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) }; @@ -110,6 +123,30 @@ impl Node { } } +/// `n`, a group of a clipping path's pieces or one of them, keeping only the pieces of its fills +/// (`fill`) or of its strokes, at every depth of the groups an effect put them in; `None` when it +/// holds none. `paints` is set when one of the kept fills or strokes paints. +fn pieces_of(n: &Node, fill: bool, paints_item: &dyn Fn(&crate::appearance::AppearanceItem) -> bool, paints: &mut bool) -> Option { + use crate::appearance::AppearanceItem; + if let NodeKind::Group { children, clip: false } = &n.kind { + let kept: Vec<_> = children.iter().filter_map(|c| pieces_of(c, fill, paints_item, paints).map(std::sync::Arc::new)).collect(); + if kept.is_empty() { + return None; + } + let mut m = n.clone(); + if let Some(ch) = m.children_mut() { + *ch = kept; + } + return Some(m); + } + let items = &n.appearance.items; + if !items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) == fill) { + return None; + } + *paints |= items.iter().any(paints_item); + Some(n.clone()) +} + /// What a clipping path paints ([`Node::clip_paint`]). #[derive(Clone, Debug, Default)] pub struct ClipPaint { diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index e13750457e..f75e473039 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -79,6 +79,10 @@ pub struct NodeExtras { /// knockout group its knockout shape is the whole area of its triangle, not only where the /// image is opaque ([`Node::knockout_outline`]). pub warp_piece: bool, + /// A group of the pieces a clipping path's fills and strokes draw once their geometry effects + /// are applied, one path or compound path per fill and stroke: as the clipping path of a clip group it paints + /// those pieces as the path paints its fills and strokes ([`Node::clip_paint`]). + pub clip_pieces: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -108,6 +112,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { pixel_aligned: false, color_mask: None, warp_piece: false, + clip_pieces: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 1aad3a27cf..cd4798c76c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -228,7 +228,11 @@ impl Node { /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice /// guides, that box also holds the art box placed by each cell's map /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a - /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. An + /// instance whose art holds a clipping path with live geometry effects also takes in the region + /// they give it ([`crate::hit::set_clip_region`]): moved with the instance when it only moves its + /// art, otherwise placed by it and grown by as far as the region reaches past the art, times √2 + /// and the larger of 1 and the Frobenius norm of the transform's linear part. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -268,6 +272,30 @@ impl Node { NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => { vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c))) } + // The live geometry effects of the clipping paths in the art can draw it past its box. + NodeKind::SymbolInstance { symbol, xf } => { + let Some((s, art, region)) = symbols.and_then(|s| { + let a = s.art(symbol)?; + Some((s, a, a.reshaped?)) + }) else { + return self.visual(symbols); + }; + let map = s.document().symbol_slice_map_of(symbol, art.geometric, *xf); + let placed = if moves_only(*xf) && map.is_none() { + xf.transform_rect_bbox(region) + } else { + // An effect can reach a fixed distance past the art or a distance in proportion + // to its size, as `SymbolBoxes::effects_reach` estimates it. + let g = art.geometric; + let all = region.union(g); + let past = [g.x0 - region.x0, region.x1 - g.x1, g.y0 - region.y0, region.y1 - g.y1].into_iter().fold(0.0_f64, f64::max); + let [a, b, c, d, _, _] = xf.as_coeffs(); + let grow = past * std::f64::consts::SQRT_2 * (a * a + b * b + c * c + d * d).sqrt().max(1.0); + map.map_or_else(|| xf.transform_rect_bbox(all), |m| m.placement_bounds(all)).inflate(grow, grow) + }; + let o = self.appearance.outset(); + vectorcraft_geom::union_opt(self.visual(symbols), self.projected(Some(placed)).map(|b| b.inflate(o, o))) + } _ => self.visual(symbols), } } @@ -369,7 +397,7 @@ impl Node { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // A clipping path that paints (see `clip_paint`) strokes over the clip edge. let clip = children.first()?; - if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) || clip.extras().clip_pieces { clip.visual(symbols) } else { clip.geometric(symbols) diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 06e68a79d0..db21762f61 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -24,6 +24,7 @@ fn extras_are_written_flat_in_order() { e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; e.warp_piece = true; + e.clip_pieces = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -71,11 +72,13 @@ fn extras_are_written_flat_in_order() { "envelope_edit_margin", "pixel_aligned", "warp_piece", + "clip_pieces", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); assert_eq!(v["warp_piece"], true); + assert_eq!(v["clip_pieces"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 885f9a5853..c47097b8cc 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -57,6 +57,7 @@ impl Serialize for Node { is_default(&e.pixel_aligned), e.color_mask.is_none(), is_default(&e.warp_piece), + is_default(&e.clip_pieces), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -102,6 +103,7 @@ impl Serialize for Node { field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); field!("warp_piece", &e.warp_piece); + field!("clip_pieces", &e.clip_pieces); st.end() } } @@ -249,6 +251,8 @@ pub(super) struct NodeWire { color_mask: Option>, #[serde(default)] warp_piece: bool, + #[serde(default)] + clip_pieces: bool, } impl From for Node { @@ -279,6 +283,7 @@ impl From for Node { pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, warp_piece: w.warp_piece, + clip_pieces: w.clip_pieces, }; Node { id: w.id, diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index d1333bef0b..b2c00d8ab3 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -173,6 +173,10 @@ pub struct PatternDef { /// Size Tile to Art: the tile is the art bounds grown by the spacing. #[serde(default)] pub size_tile_to_art: bool, + /// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by + /// the same offset. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub move_tile_with_art: bool, #[serde(default)] pub h_spacing: f64, #[serde(default)] @@ -202,6 +206,7 @@ impl PatternDef { art, overlap: Overlap::default(), size_tile_to_art: false, + move_tile_with_art: false, h_spacing: 0.0, v_spacing: 0.0, copies: 5, diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 4ef6446247..541e7801c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -49,6 +49,13 @@ pub struct Selection { /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key_guide: Option, + /// The key anchor for Align: one of two or more anchor points selected with Direct Selection, + /// the last one a Shift-click added (path, anchor in [`Self::anchors`]). Align and Distribute + /// move the other selected points relative to it, and it stays in place. Never set with `key` + /// or `key_guide`; [`Self::prune`] clears it when the point is no longer a selected anchor, or + /// fewer than two points are selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_anchor: Option<(NodeId, AnchorRef)>, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -98,6 +105,7 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.key_anchor = None; self.symbol_part = None; self.on_path_member = None; } @@ -113,9 +121,15 @@ impl Selection { self.set([instance]); self.symbol_part = Some(SymbolPart { instance, path }); } - /// Is there a key for Align: an object or a guide? + /// Is there a key for Align: an object, a guide or an anchor point? pub fn has_key(&self) -> bool { - self.key.is_some() || self.key_guide.is_some() + self.key.is_some() || self.key_guide.is_some() || self.key_anchor.is_some() + } + /// Where the key anchor ([`Self::key_anchor`]) is. `None` without one, or when the point is + /// gone from its path. + pub fn key_anchor_point(&self, doc: &Document) -> Option { + let (id, (si, ai)) = self.key_anchor?; + Some(doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p) } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -143,6 +157,9 @@ impl Selection { if self.key == Some(id) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| k == id) { + self.key_anchor = None; + } } /// [`Self::remove`] for each of `ids`, going through the selected objects once. pub fn remove_all(&mut self, ids: impl IntoIterator) { @@ -162,6 +179,9 @@ impl Selection { if self.key.is_some_and(|k| ids.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| ids.contains(&k)) { + self.key_anchor = None; + } } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { @@ -271,6 +291,40 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A key anchor needs its point on its path and selected as an anchor point (not as a + // selected segment's end), with another point selected too: another anchor, or a path + // selected whole (Align and Distribute act on path points only). It takes the place of a + // key object or key guide. + let path_of = |o: &NodeId| found.get(o).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()); + let whole_path = |o: &NodeId| !self.anchors.contains_key(o) && path_of(o).is_some_and(|path| path.anchors().next().is_some()); + // Selected anchor points that are on their paths, counted up to two. + let points = || { + self.anchors + .iter() + .flat_map(|(o, set)| { + let path = path_of(o); + set.iter().filter(move |&&(si, ai)| path.is_some_and(|p| p.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len()))) + }) + .take(2) + .count() + }; + let anchor_kept = self.key_anchor.is_some_and(|(id, (si, ai))| { + self.objects.contains(&id) + && !self.segments.contains_key(&id) + && self.anchors.get(&id).is_some_and(|set| set.contains(&(si, ai))) + && found + .get(&id) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.path_data()) + .is_some_and(|path| path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) + && (points() >= 2 || self.objects.iter().any(whole_path)) + }); + if anchor_kept { + self.key = None; + self.key_guide = None; + } else { + self.key_anchor = None; + } // A selected part needs its instance selected alone, with no guides, its symbol dynamic // and the object still in the symbol's art as a path, compound path or type, where neither // it nor a group or layer around it is hidden, locked or a template. @@ -348,6 +402,9 @@ impl Selection { if self.key.is_some_and(|k| gone.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| gone.contains(&k)) { + self.key_anchor = None; + } } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 1952060663..34faf1f9c4 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -162,6 +162,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, + BuiltinStyleLibrary { id: "neon-glow", name: "Neon & Glow", make: neon_glow }, ]; /// Built-in library `id`, made once. @@ -619,6 +620,44 @@ fn status_effects() -> Vec { ] } +/// Neon & Glow: original multi-layer procedural styles with neon tubes, laser beams, and glowing halos. +fn neon_glow() -> Vec { + vec![ + style( + "Cyan Neon", + vec![S(StrokeLayer { opacity: 0.7, ..round("#00e5ff", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.dropShadow", json!({"mode": "screen", "opacity": 85.0, "x": 0.0, "y": 0.0, "blur": 8.0, "color": "#00e5ff"}))], + ), + GraphicStyle { + blend: BlendMode::Screen, + ..style( + "Magenta Laser", + vec![S(StrokeLayer { blend: BlendMode::Screen, ..round("#ff007f", 5.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#ff007f"}))], + ) + }, + style( + "Amber Tube", + vec![S(StrokeLayer { opacity: 0.8, ..round("#ff8c00", 6.0) }), S(round("#ffea00", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#ff8c00"}))], + ), + style( + "Electric Lime", + vec![S(StrokeLayer { opacity: 0.75, ..round("#39ff14", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#39ff14"}))], + ), + style( + "Pulse Violet", + vec![S(StrokeLayer { opacity: 0.75, ..round("#7b2cbf", 5.0) }), S(round("#e0aaff", 1.5))], + vec![ + fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 75.0, "blur": 6.0, "color": "#c77dff", "source": "edge"})), + fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#7b2cbf"})), + ], + ), + style("Cobalt Phosphor", vec![S(round("#0047ab", 6.0)), S(round("#ffffff", 1.5))], vec![fx("blur.gaussian", json!({"radius": 2.0}))]), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -750,4 +789,36 @@ mod tests { let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") }; assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0)); } + + #[test] + fn neon_and_glow_styles_are_defined_with_multi_strokes_and_effects() { + let lib = builtin_style_library("neon-glow").expect("neon-glow library exists"); + assert_eq!(lib.name, "Neon & Glow"); + assert_eq!(lib.len(), 6); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]); + for g in &lib.styles { + let strokes = g.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + assert!(strokes >= 2, "{} has {} strokes, expected >= 2", g.name, strokes); + assert!(!g.appearance.effects.is_empty(), "{} has effects", g.name); + } + let cyan = lib.style("Cyan Neon").unwrap(); + assert_eq!(cyan.appearance.effects[0].id, "stylize.dropShadow"); + let magenta = lib.style("Magenta Laser").unwrap(); + assert_eq!(magenta.blend, BlendMode::Screen); + let pulse = lib.style("Pulse Violet").unwrap(); + let pulse_fx: Vec<&str> = pulse.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(pulse_fx, ["stylize.innerGlow", "stylize.outerGlow"]); + let cobalt = lib.style("Cobalt Phosphor").unwrap(); + assert_eq!(cobalt.appearance.effects[0].id, "blur.gaussian"); + } + + #[test] + fn neon_glow_round_trips_through_vcstyles() { + let lib = builtin_style_library("neon-glow").unwrap(); + let text = write(&lib); + assert!(sniff(&text)); + let read_back = read(&text, "Fallback").unwrap(); + assert_eq!(*lib, read_back); + } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 944accfe4b..99a65ae1f7 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -47,6 +47,11 @@ pub(crate) struct ArtBox { /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art /// holds one at any depth. pub(crate) sliced: bool, + /// The box of the regions that the live geometry effects of the clipping paths in the art give + /// them ([`crate::hit::set_clip_region`]), and of the instances in the art whose art has such + /// regions, each measured by [`Node::reach_bounds_with`]. `None` when nothing in the art has + /// one. + pub(crate) reshaped: Option, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -562,7 +567,18 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) + let mut reshaped = None; + if effects { + art.walk(&mut |n| { + let b = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if symbols.art(symbol).is_some_and(|a| a.reshaped.is_some()) => n.reach(Some(symbols)), + _ if n.clips() => n.children().and_then(|c| c.first()).and_then(|c| crate::hit::reshaped_clip_box(c)), + _ => None, + }; + reshaped = vectorcraft_geom::union_opt(reshaped, b.filter(finite)); + }); + } + Some(ArtBox { geometric, visual, reach, effects, brush, sliced, reshaped }) } #[cfg(test)] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 89388d8598..d165a086fb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -22,13 +22,14 @@ pub use char_style::{ WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ - COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, - CompositeParts, + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX, CharClass, + CompositeClass, CompositeClasses, CompositeFont, CompositePart, CompositeParts, CustomCharSet, }; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; +pub use runs::PATH_LEAD_MAX; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias}; pub use tabs_lists::{ Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 97f744dba8..41fbf168ae 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -298,6 +298,15 @@ impl GlyphForm { GlyphForm::QuarterWidth => b"qwid", }) } + + /// Is this one of the monospaced width forms (`fwid`, `hwid`, `twid`, `qwid`)? A font may + /// draw them with other glyphs (`GSUB`) or with other widths for the same glyphs (`GPOS`), and + /// they set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied + /// with them. + /// + pub fn is_width(self) -> bool { + matches!(self, GlyphForm::FullWidth | GlyphForm::HalfWidth | GlyphForm::ThirdWidth | GlyphForm::QuarterWidth) + } } /// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it @@ -785,6 +794,15 @@ impl CharStyle { self.position = self.position.in_range(); self.small_caps = self.small_caps.map(small_caps_in_range); } + /// Sets the East Asian glyph form ([`CharStyle::glyph_form`]). A form other than the default + /// also takes away the character's glyph choice ([`CharStyle::glyph_alternate`]), which would + /// otherwise keep drawing its own glyph; the default form keeps it. + pub fn set_glyph_form(&mut self, form: GlyphForm) { + self.glyph_form = form; + if form != GlyphForm::Default { + self.glyph_alternate = None; + } + } } impl crate::Document { diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs index e95428c2f5..93a7e27808 100644 --- a/crates/doc/src/text/composite.rs +++ b/crates/doc/src/text/composite.rs @@ -1,11 +1,16 @@ //! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the //! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font -//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its -//! family; the layout sets each character in its class's font. +//! of their own, sized, raised and scaled against it). Custom character sets give characters the +//! user picks a font of their own too, ahead of their class; of two sets that list a character, +//! the later one sets it. Type set in a composite font names it as its family; the layout sets each +//! character in its set's or its class's font. //! //! Behavior reference (public documentation): -//! . -//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. +//! , +//! custom character sets: +//! . +//! Which characters fall in which class, the ranges, the limits and the defaults are VectorCraft's +//! own. use serde::{Deserialize, Serialize}; @@ -179,12 +184,76 @@ impl CompositeParts { } } +/// A custom character set of a composite font: characters chosen by the user, set in a part of +/// their own instead of their class's part. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomCharSet { + /// The set's name, unique in its composite font in any case. + pub name: String, + /// The set's characters, each once, in the order they were added. + #[serde(default)] + pub characters: String, + /// The font, style, size, baseline and scales its characters take. + #[serde(flatten)] + pub part: CompositePart, +} + +impl CustomCharSet { + /// An empty set named `name`, its characters set in `part`. + pub fn new(name: impl Into, part: CompositePart) -> Self { + Self { name: name.into(), characters: String::new(), part } + } + + /// Whether `c` can be in a custom character set: not a control character, and in the Basic + /// Multilingual Plane (U+0000 to U+FFFF). + pub fn takes(c: char) -> bool { + !c.is_control() && (c as u32) <= 0xFFFF + } + + /// The character a Unicode code names: hexadecimal digits, optionally after `U+` or `0x` + /// (`3042`, `U+3042`). `None` for anything else or a code [`Self::takes`] refuses. + pub fn char_from_code(code: &str) -> Option { + let code = code.trim(); + let hex = ["U+", "u+", "0x", "0X"].iter().find_map(|p| code.strip_prefix(p)).unwrap_or(code); + if hex.is_empty() || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).filter(|&c| Self::takes(c)) + } +} + +/// Where a composite font sets a character: the part of one of its six classes, or the part of +/// one of its custom character sets (an index into [`CompositeFont::custom`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CompositeClass { + Fixed(CharClass), + Custom(usize), +} + +/// The custom character set each character of a composite font's sets falls in +/// ([`CompositeFont::classes`]): the last set that lists it. +#[derive(Clone, Debug, Default)] +pub struct CompositeClasses { + custom: std::collections::HashMap, +} + +impl CompositeClasses { + /// Where the composite font sets `c`: the last custom set that lists it, else its class. + pub fn of(&self, c: char) -> CompositeClass { + self.custom.get(&c).map_or(CompositeClass::Fixed(CharClass::of(c)), |&i| CompositeClass::Custom(i)) + } +} + /// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family /// that type set in it names. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CompositeFont { pub name: String, pub parts: CompositeParts, + /// Custom character sets, checked before the classes. A character two sets list is set in the + /// later set's part. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom: Vec, } /// The size a part can take, in percent of the type's size. @@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; pub const COMPOSITE_NAME_MAX: usize = 255; /// The most composite fonts a document or a file keeps. pub const COMPOSITE_FONTS_MAX: usize = 256; +/// The most custom character sets a composite font keeps. +pub const CUSTOM_SETS_MAX: usize = 64; +/// The most characters a custom character set keeps. +pub const CUSTOM_CHARS_MAX: usize = 8192; impl CompositeFont { /// A composite font named `name` setting every class in `font` and `style`. pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { - Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)), custom: Vec::new() } + } + + /// The part that sets the characters of `class`, `None` for a custom set the font doesn't have. + pub fn part(&self, class: CompositeClass) -> Option<&CompositePart> { + match class { + CompositeClass::Fixed(c) => Some(self.parts.get(c)), + CompositeClass::Custom(i) => self.custom.get(i).map(|s| &s.part), + } + } + + /// Where this font sets character `c`: the last custom set that lists it, else its class. For + /// many characters, [`Self::classes`] looks each up faster. + pub fn class_of(&self, c: char) -> CompositeClass { + self.custom.iter().rposition(|s| s.characters.contains(c)).map_or(CompositeClass::Fixed(CharClass::of(c)), CompositeClass::Custom) + } + + /// The custom set each character of this font's sets falls in, to look up many characters. + pub fn classes(&self) -> CompositeClasses { + let mut custom = std::collections::HashMap::new(); + for (i, s) in self.custom.iter().enumerate() { + for c in s.characters.chars() { + custom.insert(c, i); + } + } + CompositeClasses { custom } } /// Do `a` and `b` name the same composite font: equal in any case? @@ -223,36 +321,50 @@ impl CompositeFont { } /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a - /// part without a font, a value out of its range, or a scale or Scale at Center on a class - /// that has none. + /// part without a font, a value out of its range, a scale or Scale at Center on a class that + /// has none, or a custom character set [`Self::check_custom`] refuses. pub fn check(&self) -> Result<(), String> { if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { return Err(format!("`{}` can't name a composite font", self.name)); } for class in CharClass::ALL { - let p = self.parts.get(class); - let k = class.key(); - if p.font.trim().is_empty() { - return Err(format!("{k}: give a font")); + check_part(self.parts.get(class), class.key(), class.scales(), class == CharClass::Kana)?; + } + self.check_custom() + } + + /// Why this font's custom character sets can't be used, if they can't: more than + /// [`CUSTOM_SETS_MAX`], a name [`Self::clean_name`] refuses or two sets share (in any case), a + /// character [`CustomCharSet::takes`] refuses or a set lists twice, more than + /// [`CUSTOM_CHARS_MAX`] characters in a set, or a part [`Self::check`] would refuse. A set's + /// characters can be scaled across and up; only kana are scaled at the center. + pub fn check_custom(&self) -> Result<(), String> { + if self.custom.len() > CUSTOM_SETS_MAX { + return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets")); + } + let mut names = std::collections::HashSet::new(); + for s in &self.custom { + let name = &s.name; + if Self::clean_name(name).as_deref() != Some(name.as_str()) { + return Err(format!("`{name}` can't name a character set")); } - let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); - if !within(p.size, &COMPOSITE_SIZE) { - return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + if !names.insert(name.to_lowercase()) { + return Err(format!("two character sets are named `{name}`")); } - if !within(p.baseline, &COMPOSITE_BASELINE) { - return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); - } - for v in [p.h_scale, p.v_scale] { - if !within(v, &COMPOSITE_SCALE) { - return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + let mut seen = std::collections::HashSet::new(); + for (n, c) in s.characters.chars().enumerate() { + if n == CUSTOM_CHARS_MAX { + return Err(format!("`{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters")); } - if v != 100.0 && !class.scales() { - return Err(format!("{k}: only kana, roman and numerals can be scaled")); + let code = c as u32; + if !CustomCharSet::takes(c) { + return Err(format!("`{name}`: U+{code:04X} is a control character or past U+FFFF")); + } + if !seen.insert(c) { + return Err(format!("`{name}`: U+{code:04X} is listed twice")); } } - if p.scale_at_center && class != CharClass::Kana { - return Err(format!("{k}: only kana can be scaled at the center")); - } + check_part(&s.part, name, true, false)?; } Ok(()) } @@ -264,9 +376,41 @@ impl CompositeFont { let mut names = std::collections::HashSet::new(); let mut kept: Vec = fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); - kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + kept.retain(|f| f.part_fonts().all(|font| !names.contains(&font.to_lowercase()))); (kept.len() != fonts.len()).then_some(kept) } + + /// The fonts of the parts, each class's then each custom set's. + pub fn part_fonts(&self) -> impl Iterator { + CharClass::ALL.into_iter().map(|c| self.parts.get(c).font.as_str()).chain(self.custom.iter().map(|s| s.part.font.as_str())) + } +} + +/// Why part `p` (of the class or set named `k`) can't be used, if it can't: no font, a value out +/// of its range, a scale when `scales` is false, or Scale at Center when `center` is false. +fn check_part(p: &CompositePart, k: &str, scales: bool, center: bool) -> Result<(), String> { + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !scales { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && !center { + return Err(format!("{k}: only kana can be scaled at the center")); + } + Ok(()) } #[cfg(test)] @@ -341,4 +485,81 @@ mod tests { spaced.name = " Mixed".into(); assert!(spaced.check().is_err()); } + + /// A character a custom set lists falls in that set ahead of its class, the later of two sets + /// that list it wins, and the quick lookup agrees with the one-character one. + #[test] + fn custom_sets_take_their_characters_before_the_classes() { + let mut f = CompositeFont::new("Mixed", "Inter", "Regular"); + let mut a = CustomCharSet::new("Marks", CompositePart::new("Source Sans 3", "Bold")); + a.characters = "※A".into(); + let mut b = CustomCharSet::new("Letters", CompositePart::new("Inter", "Italic")); + b.characters = "AB".into(); + f.custom = vec![a, b]; + let classes = f.classes(); + for (c, want) in [ + ('※', CompositeClass::Custom(0)), + ('A', CompositeClass::Custom(1)), + ('B', CompositeClass::Custom(1)), + ('C', CompositeClass::Fixed(CharClass::Roman)), + ('漢', CompositeClass::Fixed(CharClass::Kanji)), + ] { + assert_eq!(f.class_of(c), want, "{c:?}"); + assert_eq!(classes.of(c), want, "{c:?}"); + } + assert_eq!(f.part(CompositeClass::Custom(0)).map(|p| p.style.as_str()), Some("Bold")); + assert_eq!(f.part(CompositeClass::Custom(2)), None); + assert_eq!(f.check(), Ok(())); + // Read and written with the part's fields beside the set's name and characters. + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["custom"][1]["characters"], "AB"); + assert_eq!(json["custom"][1]["style"], "Italic"); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + // A font without sets is written as before. + assert!(serde_json::to_value(CompositeFont::new("Plain", "Inter", "Regular")).unwrap().get("custom").is_none()); + } + + /// Set names, characters and limits are checked; sets scale like Roman letters. + #[test] + fn custom_sets_check_their_names_and_characters() { + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + let with = |sets: Vec<(&str, &str)>, edit: fn(&mut CompositePart)| { + let mut f = ok.clone(); + for (name, chars) in sets { + let mut s = CustomCharSet::new(name, CompositePart::new("Inter", "Regular")); + s.characters = chars.into(); + edit(&mut s.part); + f.custom.push(s); + } + f.check() + }; + let none = |_: &mut CompositePart| {}; + assert_eq!(with(vec![("Empty", "")], none), Ok(())); + assert_eq!(with(vec![("Wide", "AB")], |p| p.h_scale = 80.0), Ok(())); + assert!(with(vec![("Wide", "AB")], |p| p.scale_at_center = true).is_err()); + assert!(with(vec![("Big", "A")], |p| p.size = 401.0).is_err()); + assert!(with(vec![("Same", "A"), ("same", "B")], none).unwrap_err().contains("two character sets")); + assert!(with(vec![(" Pad", "A")], none).is_err()); + assert!(with(vec![("Twice", "ABA")], none).unwrap_err().contains("U+0041 is listed twice")); + assert!(with(vec![("Tab", "A\t")], none).unwrap_err().contains("U+0009")); + assert!(with(vec![("Astral", "𠀋")], none).unwrap_err().contains("U+2000B")); + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(CUSTOM_CHARS_MAX + 1).collect(); + let mut f = ok.clone(); + f.custom.push(CustomCharSet { name: "Many".into(), characters: many, part: CompositePart::new("Inter", "Regular") }); + assert!(f.check().unwrap_err().contains("at most")); + let mut f = ok.clone(); + f.custom = (0..=CUSTOM_SETS_MAX).map(|i| CustomCharSet::new(format!("S{i}"), CompositePart::new("Inter", "Regular"))).collect(); + assert!(f.check().is_err()); + } + + /// Codes are hexadecimal, with or without `U+` or `0x`, inside the Basic Multilingual Plane. + #[test] + fn custom_set_codes_name_characters() { + assert_eq!(CustomCharSet::char_from_code("3042"), Some('あ')); + assert_eq!(CustomCharSet::char_from_code(" U+203B "), Some('※')); + assert_eq!(CustomCharSet::char_from_code("0x41"), Some('A')); + for bad in ["", "U+", "zz", "20000B", "D800", "0009", "1F600", "1234567", "+41"] { + assert_eq!(CustomCharSet::char_from_code(bad), None, "{bad:?}"); + } + } } diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 9af4d9c5dc..b787cebfe0 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -352,7 +352,10 @@ impl TextObject { } /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its /// centre bracket dragged across the path): the path runs the other way and the brackets swap - /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. + /// ends, so the type keeps its stretch of the path. Type that starts before an open path's start + /// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its + /// span before the path's start would come after the path's end. False, changing nothing, for + /// other type. pub fn flip_on_path(&mut self) -> bool { let Some((s, e)) = self.kind.path_span() else { return false }; let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index 0bf62ed155..5bf6d1bf79 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -93,8 +93,10 @@ pub enum TextKind { /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). Area { frame: PathData }, /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the - /// path length; no `end`: the end of the path, or once round a closed path). See - /// [`TextKind::path_span`]. + /// path length; no `end`: the end of the path, or once round a closed path). On an open path + /// `start` may be below 0: the type starts that far before the path's start, and the characters + /// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]). + /// See [`TextKind::path_span`]. OnPath { path: PathData, start: f64, @@ -117,8 +119,22 @@ impl TextKind { (s, end.map_or(1.0, |e| fraction(e).max(s))) }) } + /// Where type on a path's text starts, as a fraction of its path's length: its start bracket + /// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most + /// [`PATH_LEAD_MAX`] path lengths before it). None for other type. + pub fn path_text_start(&self) -> Option { + let TextKind::OnPath { path, start, .. } = self else { return None }; + if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) } + } + /// Does type on an open path start before its path's start ([`Self::path_text_start`])? + pub fn starts_before_path(&self) -> bool { + self.path_text_start().is_some_and(|s| s < 0.0) + } } +/// How far before its path's start type on an open path may start, in lengths of the path. +pub const PATH_LEAD_MAX: f64 = 1e6; + /// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the /// document → text map). False when an anchor doesn't exist. pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index c3afafb6e1..0976edbb93 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() { assert_eq!(t, before); } +/// Type on an open path may start before the path's start: the start is kept, saved and read back, +/// while the start bracket stays at the path's start. On a closed path, and for a start that is not +/// finite, the start is the start bracket as before. +#[test] +fn type_on_an_open_path_starts_before_the_path() { + let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let on = |closed: bool, start: f64| TextKind::OnPath { + path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]), + start, + end: None, + }; + let open = on(false, -0.25); + assert_eq!(open.path_text_start(), Some(-0.25)); + assert!(open.starts_before_path()); + assert_eq!(open.path_span(), Some((0.0, 1.0))); + let saved = serde_json::to_value(&open).unwrap(); + assert_eq!(saved["start"], -0.25); + assert_eq!(serde_json::from_value::(saved).unwrap(), open); + assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX)); + assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0)); + assert_eq!(on(false, 0.25).path_text_start(), Some(0.25)); + assert!(!on(false, 0.0).starts_before_path()); + assert_eq!(on(true, -0.25).path_text_start(), Some(0.0)); + assert!(!on(true, -0.25).starts_before_path()); + assert_eq!(TextKind::Point.path_text_start(), None); +} + /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore /// `justify_auto`); this build reads Auto back. #[test] diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 54d97a4527..d8f3ae0fe4 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -27,7 +27,9 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{ + as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, paint_type_clip, reshape_leaves, +}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, @@ -689,6 +691,9 @@ fn moved_type_group(n: &Node) -> Option { /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + if !clipping { + paint_type_clip(Some(d), n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1c1ca96d9f..2a5ce5fd9d 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,50 +182,50 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps), render: object ({mode: shaded}; mode: shaded|wireframe, wireframe draws the edges of the faces the effect draws, each once, stroked 0.5 pt black with round joins, with no fill, shadow or graphics, whatever the opacity of the fills, strokes and group members, while the effect stays live; the stroke is VectorCraft's own)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[],"render":{"mode":"shaded"}}), ), g( EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( INFLATE, "Inflate…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"render":{"mode":"shaded"}}), ), g( ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( CLASSIC_EXTRUDE, "Extrude & Bevel (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", - json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), + "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", + json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false,"drawHiddenFaces":false}), ), g( CLASSIC_REVOLVE, "Revolve (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", - json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( CLASSIC_ROTATE, "Rotate (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, with black multiplied and overprinting where the art darkens, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( "convertToShape.rectangle", diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index cd9d67fc43..8a8730f66d 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -31,9 +31,10 @@ pub fn reshaped_clip_outline(clip: &Node) -> Option> /// become a path of their reshaped region. A path or compound path whose fills or strokes have /// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects /// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their -/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the -/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a -/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. +/// union, and the group is marked as such pieces +/// ([`vectorcraft_doc::NodeExtras::clip_pieces`]), so a clipping path that paints still paints +/// them ([`Node::clip_paint`]). `None` when nothing in it has a geometry effect. Effects on the +/// clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { with_clip_effects_applied_while(n, || true) } @@ -42,18 +43,20 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the /// members after it are not visited. The members of a group or layer that are not visited stay as /// they are, with their effects. A compound path with geometry effects of its own still becomes a -/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or -/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both -/// cases the paths in it that are not visited go -/// into that geometry as they are, and their own effects are not applied. +/// path of its reshaped geometry. A path or compound path whose fills or strokes have geometry +/// effects still becomes a group of one path per fill and stroke. In both cases the paths in it +/// that are not visited go into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { - let paint = n.clip_paint(); - clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) + let mut m = clip_effects_applied(n, true, take)?; + if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && matches!(m.kind, NodeKind::Group { clip: false, .. }) { + m.extras_mut().clip_pieces = true; + } + Some(m) } -/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the -/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry -/// effects of `n`'s own fills and strokes are left out. +/// [`with_clip_effects_applied_while`]; with `items` unset (the paths of a compound path, which +/// draw with the compound path's fills and strokes), the geometry effects of `n`'s own fills and +/// strokes are left out. fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option { let mut m: Option = None; let members: &[std::sync::Arc] = match &n.kind { @@ -88,6 +91,17 @@ fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option); -/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each -/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each -/// sublayer holding one directly or in its sublayers, and each member of another kind with such a -/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a -/// ruler guide directly or in its sublayers. The other members of a layer holding none are not -/// looked into, unless the layer is the one whose sublayers are gathered (`top`). -fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) { - let layer = n.is_layer(); - let (mut holds, mut held, mut other) = (false, vec![], vec![]); +/// The members of `n` that are ruler guides (`is_guide`), and those holding one at any depth, in +/// order. Every member at every depth is looked at once: a sublayer or group with no guide of its +/// own is still looked into, and a guide directly in a group counts as one in a layer does. A +/// member holding no guide gets no entry. +fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> HeldGuides { + let mut held = vec![]; for (i, c) in n.children().into_iter().flatten().enumerate() { if is_guide(c) { - if layer { - holds = true; - held.push((i, HeldGuides::default())); - } - } else if c.is_layer() { - let (sub_holds, inner) = held_guides(c, false, is_guide); - if sub_holds { - holds = true; - held.push((i, inner)); - } - } else { - other.push((i, c)); + held.push((i, HeldGuides::default())); + continue; + } + let inner = held_guides(c, is_guide); + if !inner.0.is_empty() { + held.push((i, inner)); } } - if !layer || holds || top { - for (i, c) in other { - let (_, inner) = held_guides(c, false, is_guide); - if !inner.0.is_empty() { - held.push((i, inner)); - } - } - held.sort_unstable_by_key(|(i, _)| *i); - } - (layer && holds, HeldGuides(held)) + HeldGuides(held) } /// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`: @@ -818,15 +801,14 @@ fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec>, hi out.append(&mut deeper); } -/// Move the ruler guides of the sublayers among the members of `n`, and among its groups' -/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken -/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or -/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only -/// containers holding one are copied. +/// Move the ruler guides of `n` and those held at any depth by the sublayers and groups among its +/// members to `out`, in order: a container's own guides, then those of its members. A sublayer or +/// group with no guide of its own still gives up those of its members. A guide taken from a hidden +/// or locked sublayer or group, or from one inside such a container, is hidden or locked itself. +/// Each member at every depth is looked at once (`is_guide`), and only containers holding a guide +/// are copied. fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, is_guide: &impl Fn(&Node) -> bool) { - let (_, mut held) = held_guides(n, true, is_guide); - // The ruler guides of `n` itself stay where they are. - held.0.retain(|(_, inner)| !inner.0.is_empty()); + let held = held_guides(n, is_guide); take_held_guides(n, held, out, false, false); } @@ -850,12 +832,15 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); - // A layer's ruler guides take no part: they go back above the evaluated art at the end. - let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); - // So do its sublayers' guides, which would otherwise go with the art the effects replace or - // copy: they stay on this layer, hidden and locked as their sublayers were. - if m.is_layer() && (pathfinder || !fx.is_empty()) { + // Its ruler guides take no part: they go back above the evaluated art at the end. With effects + // that replace or copy the art, so do its guides among the art and those in its sublayers and + // groups, which would otherwise go with that art: they stay on `n`, in order, hidden and + // locked as their containers were. + let mut guides = vec![]; + if pathfinder || !fx.is_empty() { take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide); + } else if let Some(ch) = m.children_mut() { + guides = ch.split_off(Node::guide_run_start(ch)); } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 519323e5f0..50fbf8f759 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1808,7 +1808,7 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() } /// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks -/// at each member at every depth once at most, and takes the same guides, in the same order and +/// at each member at every depth once, and takes the same guides, in the same order and /// state, as a layer's effects keep. #[test] fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { @@ -1840,7 +1840,7 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { groups.locked = depth == 3; } let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)])); - // A sublayer with no guide: the art in its group is not looked at. + // A sublayer with no guide, its group holding none either. let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]); let quiet = Arc::new(layer(600, vec![Arc::new(quiet)])); let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]); @@ -1858,15 +1858,16 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { }; let mut taken = vec![]; take_sublayer_guides(&mut m, &mut taken, &is_guide); - assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group"); - assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once"); - assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied"); - assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied"); + assert_eq!(looked.get(), members(&root), "each member is looked at once, those of guide-free containers included"); + assert_eq!(taken[0].id, NodeId(20), "the layer's own guide comes first"); + assert_eq!(state(&taken[1..]), expected, "a sublayer's own guides, then its sublayers', each once"); + assert!(Arc::ptr_eq(&taken[1], &first_guide), "a guide with nothing to change is not copied"); + assert!(!Arc::ptr_eq(&taken[2], &second_guide), "a guide made hidden is copied"); assert!(second_guide.visible, "the guide as it was stays as it was"); let ch = m.children().unwrap(); + assert_eq!(ch.len(), 5, "the layer's own guide is taken"); assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied"); assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied"); - assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is"); let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::>(); assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order"); assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides"); @@ -1892,6 +1893,140 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { } } +/// Where each ruler guide in `n` is, at every depth, with its position, visibility and lock. +fn ruler_guides_in(n: &Node, out: &mut Vec<(NodeId, NodeKind, bool, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push((c.id, c.kind.clone(), c.visible, c.locked)); + } + ruler_guides_in(c, out); + } +} + +/// A ruler guide in a layer inside a group inside a sublayer that holds no guide of its own, at +/// any depth of that pattern, survives its layer's Pathfinder effect or Transform copies once, last +/// in the layer, hidden or locked when any container on its way was. +#[test] +fn a_ruler_guide_below_a_guide_free_sublayer_and_a_group_survives_its_layers_effects() { + use vectorcraft_doc::LayerColor; + let layer = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + (l.visible, l.locked) = (!hidden, locked); + Arc::new(l) + }; + let group = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut g = Node::group(NodeId(id), children); + (g.visible, g.locked) = (!hidden, locked); + Arc::new(g) + }; + let kind = NodeKind::RulerGuide { vertical: false, pos: 7.0, artboard: None }; + let states = [(false, false), (true, false), (false, true)]; + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for depth in 1..=3_u64 { + for sub in states { + for grp in states { + for inner in states { + // Layer 10 → sublayer → group → … → layer holding the guide, the pattern + // repeated `depth` times; only the innermost layer holds a guide. + let mut n = layer(900, vec![sq(901, 0.0, Color::BLACK), Arc::new(Node::new(NodeId(20), kind.clone()))], inner); + for d in (0..depth).rev() { + let (s, g) = if d == 0 { (sub, grp) } else { ((false, false), (false, false)) }; + n = group(700 + d, vec![sq(710 + d, 5.0, Color::BLACK), n], g); + n = layer(800 + d, vec![n, sq(810 + d, 2.0, Color::BLACK)], s); + } + let mut root = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = root.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), n]; + } + root.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &root).unwrap(); + let (hidden, locked) = (sub.0 || grp.0 || inner.0, sub.1 || grp.1 || inner.1); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect}, depth {depth}, sublayer {sub:?}, group {grp:?}, layer {inner:?}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + let last = art.children().and_then(|c| c.last()).unwrap(); + assert_eq!(last.id, NodeId(20), "{case}: last in the layer"); + } + } + } + } + } +} + +/// A ruler guide directly in a group, in a layer or in a group with a Pathfinder effect or +/// Transform copies, survives that effect once, last in the container, hidden or locked as its +/// groups were. So does one among a group's art rather than after it. +#[test] +fn a_ruler_guide_in_a_group_survives_its_containers_effects() { + use vectorcraft_doc::LayerColor; + let kind = NodeKind::RulerGuide { vertical: true, pos: 4.0, artboard: None }; + let guide = || Arc::new(Node::new(NodeId(20), kind.clone())); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for (hidden, locked) in [(false, false), (true, false), (false, true)] { + let mut inner = Node::group(NodeId(12), vec![sq(2, 5.0, Color::BLACK), guide(), sq(3, 8.0, Color::BLACK)]); + (inner.visible, inner.locked) = (!hidden, locked); + let outer = Arc::new(Node::group(NodeId(11), vec![Arc::new(inner)])); + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer.clone()]; + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(13), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer]); + g.appearance.effects.push(fx.clone()); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect} on a {what}, hidden {hidden}, locked {locked}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{case}: last"); + } + } + // A group's guide placed before its art. + let mut g = Node::group(NodeId(13), vec![guide(), sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]); + g.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + assert_eq!(all, [(NodeId(20), kind.clone(), true, false)], "{effect}: a group's leading guide, once"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{effect}: moved last"); + } +} + +/// The ruler guides of a layer or group with a Pathfinder effect or Transform copies, one among +/// its art and one after it, go after the evaluated art in the order they were in. +#[test] +fn a_containers_ruler_guides_keep_their_order_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None })); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + let members = || vec![sq(1, 0.0, Color::BLACK), guide(20), sq(2, 5.0, Color::BLACK), guide(21)]; + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = members(); + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(11), members()); + g.appearance.effects.push(fx); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let ids = all.iter().map(|(id, ..)| *id).collect::>(); + assert_eq!(ids, [NodeId(20), NodeId(21)], "{effect} on a {what}: the guides, in order"); + let ch = art.children().unwrap(); + let tail = ch[ch.len() - 2..].iter().map(|c| c.id).collect::>(); + assert_eq!(tail, [NodeId(20), NodeId(21)], "{effect} on a {what}: last, in order"); + } + } +} + /// A group's own Fill + Offset Path below its contents follows the outline of a clip group among /// its members, its clipping path, not the clipped art it hides outside that path (#7289). #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a326c85cf2..e3915797f2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -81,7 +81,10 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; +pub use raster::{ + RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, rasterize_stage, stage_map, staged_effects, + without_geometry, +}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art, @@ -91,12 +94,13 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES, - ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds, - graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, - names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes, - three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, RENDER, RENDER_MODES, ROTATE, + ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, + graphic_bounds, graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, + names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, renders_wireframe, solid_art, solid_art_in, takes_graphics, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + with_graphic, }; pub use solids::{ GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index b59d61beb2..f24a5dd2ee 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -606,9 +606,62 @@ fn transform_step(e: &Effect) -> Option { (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) } +/// The Transform effects of `effects` that move the image of its last visible Rasterize effect +/// instead of the art it is made from: the indices of the visible Transform effects listed after +/// it that move the art, when from the first visible Transform effect after it on every visible +/// effect is a Transform effect that moves the art once (no copies, not at random). Effects apply +/// in the order they are listed, so the image is made of the art before those Transform effects +/// and moves with them, its pixel grid and room around the art included. `None` when there is no +/// such Transform effect, or the effects after the Rasterize effect hold anything else from there +/// on. +pub fn rasterize_stage(effects: &[Effect]) -> Option> { + let r = effects.iter().rposition(|e| e.visible && e.id == RASTERIZE)?; + let first = r + 1 + effects.get(r + 1..)?.iter().position(|e| e.visible && e.id == "distort.transform")?; + let mut at = vec![]; + for (i, e) in effects.iter().enumerate().skip(first).filter(|(_, e)| e.visible) { + if e.id != "distort.transform" || transform_step(e).is_none() { + return None; + } + if crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).0 != Affine::IDENTITY { + at.push(i); + } + } + (!at.is_empty()).then_some(at) +} + +/// `effects` with the Transform effects at `at` hidden ([`rasterize_stage`]): what the image of +/// the Rasterize effect before them is made from. +pub fn staged_effects(effects: &[Effect], at: &[usize]) -> Vec { + let mut out = effects.to_vec(); + for &i in at { + if let Some(e) = out.get_mut(i) { + e.visible = false; + } + } + out +} + +/// The map the Transform effects at `at` of `effects` ([`rasterize_stage`]) move the Rasterize +/// image by, for `staged`, the geometry the effects before them make: each one's map about its +/// reference point on the bounds of the geometry it takes, as the geometry pass applies it, the +/// last one outermost. `None` when `staged` is empty or the map is not a number. +pub fn stage_map(effects: &[Effect], at: &[usize], staged: &vectorcraft_geom::PathData) -> Option { + let mut cur = staged.clone(); + let mut b = staged.bounds()?; + let mut map = Affine::IDENTITY; + for &i in at { + let e = effects.get(i)?; + let (m, _) = crate::distort::transform_map(b, &merged_params(&e.id, &e.params), 0); + map = m * map; + cur = cur.transformed(m); + b = cur.bounds().unwrap_or(b); + } + map.is_finite().then_some(map) +} + /// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG /// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room -/// around the art. +/// around the art: the image moves with those Transform effects instead ([`rasterize_stage`]). pub(crate) fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 70e5994cb5..4c8191fd2b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option { /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and -/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]), and its fills and +/// strokes become art of their own ([`paint_type_clip`]). The swatches and patterns that +/// live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { + if !clipping { + paint_type_clip(doc, n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { @@ -766,6 +770,36 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping } } +/// When `n` is a clip group or clipping layer whose clipping path is type that paints +/// ([`Node::clip_paint`]), make it the art the canvas draws for it: the type's outlines clip +/// ([`clipping_text_outline`]), its fills as art ([`as_art`]) paint behind the clipped art inside +/// the clip, and its strokes as art paint over it, outside the clip, in a group that `n` becomes +/// (keeping its id, name and transparency) with the clip group inside. The other members stay as +/// they are. Hidden type paints nothing. +pub(crate) fn paint_type_clip(doc: Option<&Document>, n: &mut Node) { + let Some((first, rest)) = n.children().and_then(|ch| ch.split_first()) else { return }; + if !n.clips() || !matches!(first.kind, NodeKind::Text(_)) || !first.visible { + return; + } + let paint = first.clip_paint(); + if paint.fill.is_none() && paint.stroke.is_none() { + return; + } + let Some(clip) = clipping_text_outline(first) else { return }; + let art = |part: Option| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new); + let members: Vec> = std::iter::once(Arc::new(clip)).chain(art(paint.fill)).chain(rest.iter().cloned()).collect(); + let stroke = art(paint.stroke); + let id = n.id; + match (&mut n.kind, stroke) { + (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }, None) => *children = members, + (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }, Some(stroke)) => { + *children = vec![Arc::new(Node::new(id, NodeKind::Group { children: members, clip: true })), stroke]; + *clip = false; + } + _ => {} + } +} + /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4d55664a67..a7373c7e88 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve, SolidFaces}; -use crate::solids::wireframe::WireFace; +use crate::solids::wireframe::{WireFace, wireframe_params}; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, }; @@ -154,7 +154,12 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = union_bounds(&shapes).ok_or(EMPTY)?; let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; - shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + // In wireframe mode the edges are drawn with no paint copies, graphics or shadow. + let wire = wireframe_params(id, p); + shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items).filter(|_| !wire), &mut copies))?; + if wire { + return Ok(()); + } // Without graphics, or without the document they draw from, no member is measured for them. if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { @@ -176,7 +181,11 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + let wire = wireframe_params(id, p); + check_surface(&path, b, options_of(id, p), surface_item(&paints).filter(|_| !wire), &mut 0)?; + if wire { + return Ok(()); + } check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } @@ -261,7 +270,11 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V let options = options_of(kind, ¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; - check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + let wire = wireframe_params(kind, ¶ms); + check_surface(&path, b, options, (crate::paints(item) && !wire).then_some(item), &mut copies)?; + if wire { + continue; + } // The graphics of every item add their paints' copies to the graphics' budget, as they // are drawn; only the edited item's graphics are refused here. let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); @@ -324,6 +337,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; crate::solids::graphics::validate(id, p)?; + crate::solids::wireframe::validate_render(id, p)?; if id == CLASSIC_REVOLVE { classic::validate(p)?; } @@ -729,14 +743,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// or stroke's Revolve are looked up in `doc`. Each face carries the width of the stroke the canvas /// draws around it ([`surface`]). pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result { - let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, String> { - if fx.id == CLASSIC_REVOLVE { - let p = merged_params(&fx.id, &fx.params); - let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; - return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); - } - Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) - }; + let faces = wire_faces; if is_container(n) { let index = n .appearance @@ -753,7 +760,9 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result Result, String> { + if fx.id == CLASSIC_REVOLVE { + let p = merged_params(&fx.id, &fx.params); + let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; + return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); + } + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) +} + +/// The art Revolve `fx` in wireframe mode draws for object `id` from the profiles `paths`, all swept +/// about the bounds `b` with `options`: the edges of their faces ([`crate::solids::wireframe::wire_art`]). +/// `None` when a profile can't be swept or past the edge limit, when the canvas draws the source art. +fn wire_art<'a>(id: NodeId, paths: impl IntoIterator, b: Rect, fx: &Effect, options: Revolve) -> Option> { + let mut faces = vec![]; + for path in paths { + faces.extend(wire_faces(path, b, fx, options).ok()?.into_iter().map(|f| f.path)); + } + crate::solids::wireframe::wire_art(id, &faces) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n @@ -825,13 +856,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; - if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? - { - children.push(Arc::new(shadow)); + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of the faces, with no shadow. + children.push(wire_art(n.id, [&path], path.bounds()?, fx, fx_options(fx))?); + } else { + let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; + if let Some(b) = path.bounds() + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? + { + children.push(Arc::new(shadow)); + } + children.push(Arc::new(surface)); } - children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = past_effect(&n.appearance.effects, index); remaining.retain(|e| !is_revolve(&e.id)); @@ -851,6 +887,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + if crate::solids::renders_wireframe(fx) { + let mut art = wire_art(n.id, [&p], p.bounds()?, fx, fx_options(fx))?; + Arc::make_mut(&mut art).appearance.effects = + item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); + children.push(art); + continue; + } let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() @@ -1000,6 +1043,14 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of every member's faces, with no shadow. + let paths = shapes.iter().filter(|s| surface_item(&s.items).is_some()).map(|s| &s.path); + let mut art = n.clone(); + art.kind = NodeKind::Group { children: vec![wire_art(n.id, paths, b, fx, options)?], clip: false }; + art.appearance = Appearance { effects: past_effect(&n.appearance.effects, index), ..Default::default() }.into(); + return Some(art); + } let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = Copies::default(); let mut surfaces = vec![]; diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 1881381567..8dc013e9dc 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -23,7 +23,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 2] = ["cap", "lightBehind"]; +const FLAGS: [&str; 3] = ["cap", "lightBehind", "preserveSpotColors"]; /// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, /// rotation and perspective, built unshaded, since the classic surfaces light its faces. @@ -118,6 +118,52 @@ mod tests { } } + /// With Preserve Spot Colors, a fill linked to a spot swatch stays linked on every face: the + /// highlight lowers its tint, and the shadow is black multiplied and overprinting over it. + #[test] + fn preserve_spot_colors_keeps_the_swatch_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let spot = |params: Value| { + let mut n = profile(); + *n.appearance = Appearance::basic(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 0.5 }, Paint::None, 0.0); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + }; + let art = spot(json!({"preserveSpotColors": true})); + let mut darkened = 0; + for face in faces(&art) { + let mut fills = face.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }); + match fills.next().map(|f| &f.paint) { + Some(Paint::Solid { color, swatch: Some(name), tint }) => { + assert_eq!(name, "Ink"); + assert!(*tint > 0.0 && *tint <= 0.5, "{tint}"); + assert_eq!(*color, ink.tinted(*tint / 0.5), "the color is the swatch at the face's tint"); + } + other => panic!("the face keeps its swatch: {other:?}"), + } + if let Some(shadow) = fills.next() { + assert!(matches!(shadow.paint.color(), Some(Color::Gray { .. })), "{:?}", shadow.paint); + assert_eq!((shadow.blend, shadow.overprint), (vectorcraft_doc::color::BlendMode::Multiply, true)); + darkened += 1; + } + } + assert!(darkened > 0, "faces turned from the light are darkened"); + // Off, and with a custom shading color, the faces are process mixes again. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#336699"})] { + let art = spot(params.clone()); + let linked = faces(&art) + .iter() + .filter(|f| { + f.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { swatch: Some(_), .. }))) + }) + .count(); + assert!(linked < faces(&art).len(), "{params}: shaded faces lose the swatch"); + } + } + #[test] fn angle_offset_edge_and_cap_shape_the_surface() { let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); @@ -207,6 +253,7 @@ mod tests { (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": "on"}), "`preserveSpotColors` must be true or false"), (json!({"surface": "glossy"}), "`surface` must be one of"), (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 6fad9eac9c..cdc2376724 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -36,7 +36,7 @@ pub use graphics::{ pub(crate) mod shadow; pub(crate) mod wireframe; -pub use wireframe::three_d_wireframe_in; +pub use wireframe::{RENDER, RENDER_MODES, renders_wireframe, three_d_wireframe_in}; pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; @@ -274,6 +274,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; shadow::validate(p)?; graphics::validate(id, p)?; + wireframe::validate_render(id, p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -324,7 +325,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - return cast_shadow(id, p, &shapes).map(|_| ()); + return check_cast(id, p, &shapes); } let (path, rule) = object_profile(n, label)?; let anchors = || format!("{label} requires a shape with at least two anchors"); @@ -342,7 +343,16 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - cast_shadow(id, p, &shapes).map(|_| ()) + check_cast(id, p, &shapes) +} + +/// With the shadow of 3D effect `id` with parameters `p` on, an error when the shadow of `shapes` +/// is past the limits ([`cast_shadow`]). In wireframe mode no shadow is drawn, and none is checked. +fn check_cast(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { + if wireframe::wireframe_params(id, p) { + return Ok(()); + } + cast_shadow(id, p, shapes).map(|_| ()) } /// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` @@ -1420,6 +1430,10 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every /// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + // In wireframe mode the effect draws the edges of the faces it builds, with no shadow. + if wireframe::renders_wireframe(fx) { + return Some((vec![], wireframe::wire_surfaces(n.id, fx, shapes)?)); + } let p = crate::merged_params(&fx.id, &fx.params); let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?; @@ -1434,8 +1448,11 @@ fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> } /// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`: -/// none with the shadow off, and an error when it is past the limits. +/// none with the shadow off or in wireframe mode, and an error when it is past the limits. pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result>, String> { + if wireframe::renders_wireframe(fx) { + return Ok(vec![]); + } Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? { Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)), None => vec![], diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 29c05406a2..f48b94bce9 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -102,7 +102,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; +const FLAGS: [&str; 5] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces", "preserveSpotColors"]; /// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and @@ -127,7 +127,7 @@ const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ /// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & /// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. pub fn validate_rotate(p: &Value) -> Result<(), String> { - validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind", "preserveSpotColors"])?; if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); } @@ -192,6 +192,35 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// Whether Preserve Spot Colors applies with parameters `p`: `preserveSpotColors` is on and the +/// shading color is not Custom, which cannot keep a spot color. +fn preserves_spot_colors(p: &Value) -> bool { + flag(p, "preserveSpotColors", false) && text(p, "shadingColor", "black") != "custom" +} + +/// A face of `paint`, a color linked to a swatch, with Preserve Spot Colors: the swatch at a lower +/// tint for the highlight `spec`, its link kept so a spot color stays on its own plate, and, where +/// tone `tone` is below full light and the faces darken (`shade`), black over it at `1 - tone`, +/// multiplied and overprinting, so it prints on the black plate. `seam` is the width of the seam +/// stroke each layer draws around the face. +fn linked_face(paint: &Paint, tone: f64, spec: f64, shade: Option, seam: f64) -> Vec { + let Paint::Solid { color, swatch, tint } = paint else { return vec![] }; + let keep = (1.0 - spec).clamp(0.0, 1.0) as f32; + let fill = Paint::Solid { color: color.tinted(keep), swatch: swatch.clone(), tint: tint * keep }; + let mut items = vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]; + if shade.is_some() && tone < 1.0 { + let black = Paint::solid(Color::gray((1.0 - tone).clamp(0.0, 1.0) as f32)); + let mut layer = FillLayer::new(black.clone()); + layer.blend = BlendMode::Multiply; + layer.overprint = true; + let mut edge = crate::lighting::seam_stroke(black, seam); + edge.blend = BlendMode::Multiply; + edge.overprint = true; + items.extend([AppearanceItem::Fill(layer), AppearanceItem::Stroke(edge)]); + } + items +} + /// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on /// Wireframe, else the seam between faces. pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { @@ -200,12 +229,14 @@ pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { /// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by -/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by +/// its tone and highlight (with Preserve Spot Colors, a color linked to a swatch as [`linked_face`] +/// draws it). A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); + let preserve = preserves_spot_colors(p); let wire = shading.surface == ClassicSurface::Wireframe; let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; let paint = &paint; @@ -229,6 +260,10 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), ] } + // Preserve Spot Colors: a color linked to a swatch stays a tint of that swatch. + Some(_) if preserve && matches!(paint, Paint::Solid { swatch: Some(_), .. }) && !paint.is_registration() => { + linked_face(paint, tone, spec, shade, seam) + } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 945eb639aa..de1ec75de3 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId, StrokeLayer}; +use vectorcraft_doc::{Document, FillLayer, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -2015,6 +2015,62 @@ fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { } } +/// The fills of each face of `art`: the first is the face's paint, a second the shadow over it. +fn face_fills(art: &Node) -> Vec> { + faces(art) + .iter() + .map(|f| { + f.appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.clone()), + AppearanceItem::Stroke(_) => None, + }) + .collect() + }) + .collect() +} + +#[test] +fn classic_preserve_spot_colors_keeps_a_linked_fill_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let art = |params: Value| { + let mut n = classic(params); + n.appearance.items = vec![AppearanceItem::Fill(FillLayer::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }))]; + solid_art(&n).unwrap() + }; + let lit = json!({"preserveSpotColors": true, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + let (mut highlighted, mut shaded) = (false, false); + for layers in face_fills(&art(lit)) { + let Paint::Solid { color, swatch: Some(name), tint } = &layers[0].paint else { panic!("the swatch is kept: {layers:?}") }; + assert_eq!(name, "Ink"); + assert_eq!(*color, ink.tinted(*tint), "the color is the swatch at the face's tint"); + highlighted |= *tint < 1.0; + if let Some(shadow) = layers.get(1) { + let Some(Color::Gray { k }) = shadow.paint.color() else { panic!("black shading: {:?}", shadow.paint) }; + assert!(k > 0.0 && k <= 1.0, "{k}"); + assert_eq!((shadow.blend, shadow.overprint, shadow.opacity), (BlendMode::Multiply, true, 1.0)); + shaded = true; + } + assert!(layers.len() <= 2, "{layers:?}"); + } + assert!(highlighted && shaded, "the highlight lowers the tint and the shadow adds black"); + // No shading color: the highlight only, as tints of the swatch. + for layers in face_fills(&art(json!({"preserveSpotColors": true, "shadingColor": "none"}))) { + assert!(matches!(&layers[..], [f] if matches!(&f.paint, Paint::Solid { swatch: Some(_), .. })), "{layers:?}"); + } + // Off, or with a custom shading color, shaded faces are process mixes, as before. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#3366cc"})] { + let unlinked = face_fills(&art(params.clone())).iter().filter(|l| matches!(l[0].paint, Paint::Solid { swatch: None, .. })).count(); + assert!(unlinked > 0, "{params}"); + } + // An unlinked fill is shaded as it is with the option off. + let off: Vec<[f32; 3]> = classic_faces(json!({})).iter().map(fill_rgb).collect(); + let on: Vec<[f32; 3]> = classic_faces(json!({"preserveSpotColors": true})).iter().map(fill_rgb).collect(); + assert_eq!(off, on); +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); @@ -2034,6 +2090,7 @@ fn classic_parameters_are_checked() { (json!({"highlightSize": "big"}), "`highlightSize` must be a number"), (json!({"cap": "yes"}), "`cap` must be true or false"), (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"), + (json!({"preserveSpotColors": "yes"}), "`preserveSpotColors` must be true or false"), (json!({"bevel": "fancy"}), "bevel must be none or one of"), (json!({"twist": 10}), "unknown parameter `twist`"), (json!({"lights": []}), "lights must contain one to ten lights"), @@ -2110,6 +2167,7 @@ fn classic_rotate_parameters_are_checked() { (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": null}), "`preserveSpotColors` must be true or false"), (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), (json!({"shadow": true}), "unknown parameter `shadow`"), diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 410d51443f..65ac8b0f7e 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -1,22 +1,91 @@ //! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that -//! can be edited on their own. +//! can be edited on their own. Also the 3D and Materials panel's Render Settings › Wireframe, which +//! draws those edges in place of the shaded faces while the effect stays live. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; +use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, StrokeLayer}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, - members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, + is_three_d, label_of, members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, + union_bounds, }; use crate::{GeomContext, is_geometry}; /// The most distinct edges one object's wireframe may have. pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; +/// The parameter that holds a 3D effect's render settings: an object whose `mode` is one of +/// [`RENDER_MODES`]. +pub const RENDER: &str = "render"; + +/// The render modes of a 3D effect: `shaded` draws the lit faces, `wireframe` the edges of every +/// face ([`renders_wireframe`]). +pub const RENDER_MODES: [&str; 2] = ["shaded", "wireframe"]; + +/// The 3D effects that take [`RENDER`]: the 3D and Materials panel's Extrude & Bevel, Revolve, +/// Inflate and Rotate. The 3D (Classic) effects have their own Wireframe surface. +const TAKES_RENDER: [&str; 4] = [EXTRUDE, crate::REVOLVE, INFLATE, ROTATE]; + +/// The weight of a rendered wireframe edge, in points: the weight Expand as Wireframes strokes its +/// edges with (VectorCraft's own). +pub(crate) const WIRE_WEIGHT: f64 = 0.5; + +/// Does effect `fx` draw as a wireframe: is it one of the effects that take [`RENDER`], with its +/// `render.mode` set to `wireframe`? +pub fn renders_wireframe(fx: &Effect) -> bool { + wireframe_params(&fx.id, &fx.params) +} + +/// Do 3D effect `id`'s parameters `p` set it to draw as a wireframe ([`renders_wireframe`])? A +/// command that sets them is checked against the art that mode draws: the faces' edges, with no +/// shadow, graphics or paint copies. +pub(crate) fn wireframe_params(id: &str, p: &Value) -> bool { + TAKES_RENDER.contains(&id) && p.get(RENDER).and_then(|r| r.get("mode")).and_then(Value::as_str) == Some("wireframe") +} + +/// Check the render settings in 3D effect `id`'s parameters `p`: `render`, when given to one of the +/// effects in [`TAKES_RENDER`], is an object that holds only `mode`, one of [`RENDER_MODES`] (a +/// missing `mode` is `shaded`). The 3D (Classic) effects refuse it as a parameter they do not take. +pub(crate) fn validate_render(id: &str, p: &Value) -> Result<(), String> { + let Some(render) = p.get(RENDER).filter(|_| TAKES_RENDER.contains(&id)) else { return Ok(()) }; + let valid = render + .as_object() + .is_some_and(|fields| fields.iter().all(|(key, value)| key == "mode" && value.as_str().is_some_and(|m| RENDER_MODES.contains(&m)))); + if !valid { + return Err("`render` must be an object holding only `mode`, set to shaded or wireframe".into()); + } + Ok(()) +} + +/// The art a 3D effect in wireframe mode draws for object `id` in place of its shaded faces: a group +/// of the edges of `faces`, each edge once ([`edges`]), stroked [`WIRE_WEIGHT`] black with round +/// joins and no fill, as Expand as Wireframes strokes them. Like Expand as Wireframes and the +/// Outline view, the mesh shows every edge whatever the opacity, blending mode or opacity mask of +/// the fills, strokes and group members it is built from; the object's own transparency applies to +/// the art as a whole. `None` past [`MAX_WIRE_EDGES`] edges, when the canvas draws the source art, +/// as it does for a solid past its limits. +pub(crate) fn wire_art(id: NodeId, faces: &[PathData]) -> Option> { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + let appearance = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() }; + let children = edges(faces).ok()?.into_iter().map(|path| Arc::new(Node::path(id, path, appearance.clone()))).collect(); + Some(Arc::new(Node::group(id, children))) +} + +/// The art 3D effect `fx` in wireframe mode draws for object `id` from `shapes`: the edges of the +/// faces it builds ([`wire_art`]). `None` when a solid can't be built or past the edge limit, when +/// the canvas draws the source art. +pub(crate) fn wire_surfaces(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Option>> { + let faces: Vec = facet_paths(fx, shapes).ok()?.into_iter().map(|f| f.path).collect(); + Some(vec![wire_art(id, &faces)?]) +} + /// The grid points are rounded to before edges are compared, in points: two faces that share an /// edge give it once. const GRID: f64 = 1.0 / 1024.0; @@ -171,7 +240,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result Result, String> { +pub(crate) fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 735be60d6b..cb92d59e72 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -281,6 +281,210 @@ fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { assert_eq!(both, alone); } +/// The leaves `art` draws: each path with its fill and stroke. +fn leaves(art: &Node) -> Vec<(PathData, bool, Option)> { + let mut out = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.clone(), c.appearance.fill().is_some(), c.appearance.stroke().cloned())); + } + }); + out +} + +/// The art the canvas draws for `n`: Revolve's, else the other 3D effects'. +fn drawn(n: &Node) -> Node { + crate::revolve_art(n).or_else(|| super::super::solid_art(n)).unwrap_or_else(|| panic!("no art for {n:?}")) +} + +const WIRE: [&str; 4] = [super::super::EXTRUDE, crate::REVOLVE, super::super::INFLATE, ROTATE]; + +/// Render Settings › Wireframe: each of the panel's four effects draws the edges Expand as +/// Wireframes gives, each a 0.5 pt black stroke with round joins and no fill, in place of its +/// shaded faces; shaded again, it draws filled faces. +#[test] +fn wireframe_mode_draws_the_expanded_edges_as_strokes() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&drawn(&n)); + let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(art.len(), wire.len(), "{id}"); + for ((path, filled, stroke), edge) in art.iter().zip(&wire) { + assert!(!filled, "{id}: an edge has no fill"); + let stroke = stroke.as_ref().unwrap_or_else(|| panic!("{id}: an edge is stroked")); + assert_eq!((stroke.width, stroke.join, &stroke.paint), (WIRE_WEIGHT, LineJoin::Round, &Paint::solid(Color::BLACK)), "{id}"); + assert_eq!(segments(std::slice::from_ref(path)), segments(std::slice::from_ref(edge)), "{id}"); + } + let shaded = leaves(&drawn(&solid_square(id, json!({"render": {"mode": "shaded"}})))); + assert!(shaded.iter().any(|(_, filled, _)| *filled), "{id}: shaded faces are filled"); + } + // The box turned off axis shows the nine edges of its three faces. + let n = solid_square(super::super::EXTRUDE, json!({"render": {"mode": "wireframe"}})); + let paths: Vec = leaves(&drawn(&n)).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 9); +} + +/// The wireframe stays live: a deeper extrusion and a turned view redraw the edges. +#[test] +fn wireframe_mode_follows_the_effects_settings() { + let paths = |p: Value| -> Vec { leaves(&drawn(&solid_square(super::super::EXTRUDE, p))).into_iter().map(|(p, ..)| p).collect() }; + let flat = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 10.0}))); + let deep = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 200.0}))); + assert!(deep.width() > flat.width() + 1.0 || deep.height() > flat.height() + 1.0, "{flat:?} {deep:?}"); + let turned = paths(json!({"render": {"mode": "wireframe"}, "rotationY": 60.0})); + assert_ne!(segments(&turned), segments(&paths(json!({"render": {"mode": "wireframe"}})))); +} + +/// In wireframe mode no shadow and no graphic is drawn: only the edges. +#[test] +fn wireframe_mode_draws_no_shadow() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}, "shadow": true})); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id}"); + } +} + +/// The mode works where the effect is on a fill and on a group: the fill's solid and every member's +/// solid draw as edges, and the geometry effects after a fill's effect still apply to them. +#[test] +fn wireframe_mode_on_a_fill_and_on_a_group() { + for id in WIRE { + let mut n = solid_square(id, json!({})); + let mut fx = n.appearance.effects.remove(0); + fx.params["render"] = json!({"mode": "wireframe"}); + let fill = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + fill.effects_mut().push(fx.clone()); + fill.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 500.0})).unwrap()); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a fill"); + + let member = |x: f64, id: u64| { + Arc::new(Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let mut g = Node::group(NodeId(1), vec![member(100.0, 2), member(200.0, 3)]); + g.appearance.effects.push(fx); + let art = leaves(&drawn(&g)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a group"); + // Both members' edges are drawn. + let paths: Vec = art.into_iter().map(|(p, ..)| p).collect(); + assert!(bounds(&paths).width() > 100.0, "{id}"); + } +} + +/// The mesh shows every edge, as Expand as Wireframes gives them, whatever the opacity of the fill +/// and the opacity masks of the group's members; the object's own opacity stays on the art. Two +/// members that touch, one at 50% and one masked, draw their shared edge once: seven edges. +#[test] +fn the_mesh_shows_every_edge_whatever_the_members_transparency() { + let member = |x: f64, id: u64, opacity: f32| { + let mut n = Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = opacity; + } + n + }; + let mut masked = member(150.0, 3, 1.0); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(member(0.0, 900, 1.0), true))); + let mut g = Node::group(NodeId(1), vec![Arc::new(member(100.0, 2, 0.5)), Arc::new(masked)]); + g.opacity = 0.75; + g.appearance.effects.push(crate::new_effect(ROTATE, &json!({"render": {"mode": "wireframe"}})).unwrap()); + let art = drawn(&g); + assert_eq!(art.opacity, 0.75); + let paths: Vec = leaves(&art).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 7, "{paths:?}"); + assert_eq!(segments(&paths).len(), segments(&three_d_wireframe_in(None, &g).unwrap()).len()); + let mut scoped = 0; + for c in art.children().into_iter().flatten() { + c.walk(&mut |c| scoped += usize::from(c.extras().mask.is_some() || c.opacity < 1.0)); + } + assert_eq!(scoped, 0, "no part of the mesh is masked or faded"); + // A fill at 0% still shows its mesh. + for id in WIRE { + let mut n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = 0.0; + } + assert!(!leaves(&drawn(&n)).is_empty(), "{id}"); + } +} + +/// Expand Appearance of a Revolve in wireframe mode gives the stroked edges, not the shaded faces. +#[test] +fn expanding_a_revolve_in_wireframe_mode_gives_the_edges() { + let n = solid_square(crate::REVOLVE, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&crate::revolve::expand_revolve_art(&n).unwrap()); + assert_eq!(art.len(), three_d_wireframe_in(None, &n).unwrap().len()); + assert!(art.iter().all(|(_, filled, stroke)| !filled && stroke.as_ref().is_some_and(|s| s.width == WIRE_WEIGHT))); +} + +/// `render` is checked before an edit: an object holding only `mode`, `shaded` or `wireframe`, and +/// only on the panel's four effects. +#[test] +fn render_settings_are_validated() { + for id in WIRE { + for good in [json!({}), json!({"render": {}}), json!({"render": {"mode": "shaded"}}), json!({"render": {"mode": "wireframe"}})] { + assert_eq!(crate::validate_three_d_params(id, &good), Ok(()), "{id} {good}"); + } + for bad in [ + json!({"render": "wireframe"}), + json!({"render": null}), + json!({"render": {"mode": "raytrace"}}), + json!({"render": {"mode": true}}), + json!({"render": {"mode": "wireframe", "quality": "high"}}), + ] { + let e = crate::validate_three_d_params(id, &bad).unwrap_err(); + assert_eq!(e, "`render` must be an object holding only `mode`, set to shaded or wireframe", "{id} {bad}"); + } + // The command check on the object refuses it too. + let n = solid_square(id, json!({})); + assert!(crate::validate_three_d(&n, id, &json!({"render": {"mode": "x"}})).is_err(), "{id}"); + } + for id in [super::super::CLASSIC_EXTRUDE, crate::CLASSIC_REVOLVE, super::super::CLASSIC_ROTATE] { + let e = crate::validate_three_d_params(id, &json!({"render": {"mode": "wireframe"}})).unwrap_err(); + assert!(e.contains("`render`"), "{id}: {e}"); + } +} + +/// A Revolve whose shaded surface is past the budget of paint copies (a gradient with many stops) is +/// refused shaded, but in wireframe mode, which draws only the black edges, it is accepted and drawn: +/// on the object and on a fill. +#[test] +fn wireframe_mode_is_checked_against_the_edges_it_draws() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let n = solid_square(crate::REVOLVE, json!({})); + let path = n.path_data().unwrap().clone(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), crate::revolve::revolve_options(&json!({}))).unwrap().len(); + let stops = crate::revolve::MAX_PAINT_COPIES / (faces * 4) / std::mem::size_of::() + 1; + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = n; + n.appearance = Appearance::basic(gradient, Paint::None, 0.0).into(); + let shaded = json!({}); + let wire = json!({"render": {"mode": "wireframe"}}); + let refused = crate::validate_revolve(&n, &shaded).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(crate::validate_revolve(&n, &wire), Ok(())); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &wire).unwrap()); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); + // On the fill. + let fx = n.appearance.effects.remove(0); + let at = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + assert!(crate::revolve::validate_item_revolve(&n, &shaded, at, 0).is_err()); + assert_eq!(crate::revolve::validate_item_revolve(&n, &wire, at, 0), Ok(())); + n.appearance.items[at].effects_mut().push(fx); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); +} + +/// A 3D (Classic) effect ignores a `render` it does not take in a loaded file: it draws as before. +#[test] +fn a_classic_effect_is_not_drawn_as_a_render_wireframe() { + let mut n = solid_square(super::super::CLASSIC_EXTRUDE, json!({})); + n.appearance.effects[0].params["render"] = json!({"mode": "wireframe"}); + assert!(!renders_wireframe(&n.appearance.effects[0])); + assert!(leaves(&drawn(&n)).iter().any(|(_, filled, _)| *filled)); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index bf63df61ab..57566fbb28 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -386,9 +386,111 @@ fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { let baked = bake_document(&d).unwrap(); let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); assert!(close(exported), "exported: {exported:?}"); - // A clipping path that paints its fill keeps it, a path with the fill's effect live. + // A clipping path that paints its fill clips alike and paints the fill it clips by. let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); - assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); + assert!(close(clip_outline(&painted).unwrap().0.bounding_box()), "painted: {painted:?}"); + let fill = painted.clip_paint().fill.unwrap(); + assert!(close(fill.geometric_bounds().unwrap()), "painted fill: {fill:?}"); +} + +#[test] +fn a_painted_clipping_paths_fill_and_stroke_effects_apply_before_the_effects_around_it() { + // A clipping path with a black fill grown by Offset Path 10 and a 2 pt blue stroke moved 30 pt + // right by a Transform, in a group that its Transform scales to 50 %: the fill paints the grown + // square at half size, 60 pt across, and the stroke the square at half size, 15 pt right of + // it, on the canvas and in an export alike. + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("path.offsetPath", json!({"offset": 10}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveH": 30}))), + } + } + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip.clone()))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let check = |what: &str, c: &Node| { + let paint = c.clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((fill.width() - 60.0).abs() < 0.5 && (fill.height() - 60.0).abs() < 0.5, "{what} fill: {fill:?}"); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((stroke.width() - 50.0).abs() < 0.5 && (stroke.x0 - fill.x0 - 20.0).abs() < 0.5, "{what} stroke: {stroke:?} fill: {fill:?}"); + }; + let evaluated = crate::evaluate_container(None, &outer).unwrap(); + let canvas = first_clip(&evaluated).unwrap(); + check("canvas", &canvas); + // The clip group's box takes in the stroke's width, as for a path that paints its stroke. + let stroke = canvas.clip_paint().stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + let visual = evaluated.children().and_then(|ch| ch.first()).and_then(|g| g.visual_bounds()).unwrap(); + assert!(visual.x1 > stroke.x1 + 0.25, "{visual:?} {stroke:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + check("exported", &baked_clip(&d)); + // Without effects around it, the export paints the fill and the stroke as the canvas does. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(2, clip.clone())).unwrap(); + let paint = baked_clip(&d).clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds); + assert_eq!(fill.map(|f| f.width()), Some(120.0), "{paint:?}"); + assert_eq!(stroke.map(|s| s.x0), Some(80.0), "{paint:?}"); + // A piece that an effect around it splits into several paths (a compound path), or puts in a + // group with its copies, still paints. + let NodeKind::Group { children, .. } = &canvas.kind else { panic!("not the pieces: {canvas:?}") }; + let mut split = canvas.as_ref().clone(); + let mut compound = children[0].as_ref().clone(); + compound.kind = NodeKind::Compound { children: vec![children[0].clone(), children[0].clone()], rule: vectorcraft_geom::FillRule::NonZero }; + let copies = Node::group(NodeId(9), vec![children[1].clone(), children[1].clone()]); + if let Some(ch) = split.children_mut() { + ch[0] = Arc::new(compound); + ch[1] = Arc::new(copies); + } + let paint = split.clip_paint(); + assert!(paint.fill.is_some() && paint.stroke.is_some(), "{split:?}"); + let stroke = paint.stroke.unwrap(); + assert_eq!(stroke.children().map(|ch| ch.len()), Some(1), "only the stroke's group: {stroke:?}"); +} + +#[test] +fn a_painted_clipping_paths_stroke_keeps_the_width_its_transform_scales_it_to() { + // A compound clipping path with a 2 pt stroke whose Transform scales strokes to 200 % without + // transforming the object: the export paints the stroke at 4 pt. + let square = Node::path(NodeId(4), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut clip = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(square)], rule: vectorcraft_geom::FillRule::NonZero }); + clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into(); + let scale = json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Stroke(s) = item { + s.effects.push(fx("distort.transform", scale.clone())); + } + } + fn widths(n: &Node, out: &mut Vec) { + for item in &n.appearance.items { + if let vectorcraft_doc::AppearanceItem::Stroke(s) = item { + out.push(s.width); + } + } + n.children().into_iter().flatten().for_each(|c| widths(c, out)); + } + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(2, clip)).unwrap(); + let baked = baked_clip(&d); + let stroke = baked.clip_paint().stroke.unwrap(); + let mut found = vec![]; + widths(&stroke, &mut found); + assert!(!found.is_empty() && found.iter().all(|w| (w - 4.0).abs() < 1e-6), "{found:?} {stroke:?}"); + // The stroke is still drawn where the path lies, as the Transform does not move the object. + let bounds = stroke.geometric_bounds().unwrap(); + assert!((bounds.x0 - 50.0).abs() < 0.5 && (bounds.width() - 100.0).abs() < 0.5, "{bounds:?}"); } #[test] diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 1ebcab785a..9fbb37a39d 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -117,13 +117,71 @@ fn a_hidden_shadow_follows_the_transform_it_loses() { assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); } -/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +/// Rasterize keeps its room around the art: its image keeps its own pixel grid, and the Transform +/// effect moves the image instead ([`crate::rasterize_stage`]). #[test] fn rasterize_before_a_transform_keeps_its_options() { let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; assert_eq!(crate::raster::followed(&effects)[0], rasterize); assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); + assert_eq!(crate::rasterize_stage(&effects), Some(vec![1])); +} + +/// The Transform effects after the last Rasterize effect move its image when, from the first of +/// them on, every visible effect is a Transform effect that moves the art once. Hidden ones, and +/// ones with Transform Objects off, leave the image where it is. +#[test] +fn rasterize_following_ignores_hidden_and_objects_off() { + let rasterize = || fx(crate::RASTERIZE, json!({})); + let scale = || transform(json!({"scaleH": 200, "scaleV": 200})); + let mut hidden = scale(); + hidden.visible = false; + let off = transform(json!({"scaleH": 200, "objects": false})); + let roughen = fx("distort.roughen", json!({})); + let shadow = shadow(5.0, 5.0, 2.0); + let cases: Vec<(Vec, Option>)> = vec![ + (vec![rasterize(), hidden.clone()], None), + (vec![rasterize(), off.clone()], None), + (vec![rasterize(), hidden, off, scale()], Some(vec![3])), + (vec![rasterize(), transform(json!({"moveH": 10})), scale()], Some(vec![1, 2])), + // Effects before the first Transform effect go into the image; anything after it holds + // the image back. + (vec![rasterize(), roughen.clone(), shadow.clone(), scale()], Some(vec![3])), + (vec![rasterize(), scale(), roughen], None), + (vec![rasterize(), scale(), shadow], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "copies": 2}))], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "random": true}))], None), + // The Transform effect before the Rasterize effect moves the art. + (vec![scale(), rasterize()], None), + (vec![scale(), rasterize(), rasterize(), scale()], Some(vec![3])), + ]; + for (effects, want) in cases { + let ids: Vec<&str> = effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(crate::rasterize_stage(&effects), want, "{ids:?}"); + } + // What the image is made from hides those Transform effects; the rest stay as they are. + let effects = [rasterize(), scale()]; + let staged = crate::staged_effects(&effects, &[1]); + assert_eq!((staged[0].visible, staged[1].visible), (true, false)); +} + +/// The map that moves the image is each Transform effect's map about its reference point on the +/// bounds of the geometry it takes, in order. +#[test] +fn the_image_moves_by_each_transform_about_the_art_it_takes() { + let staged = shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)); + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"moveH": 5}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + // Twice the size about the center (20, 20), then 5 pt to the right. + let want = Affine::translate((5.0, 0.0)) * Affine::translate((20.0, 20.0)) * Affine::scale(2.0) * Affine::translate((-20.0, -20.0)); + assert!(map.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "{map:?}"); + // The second Transform effect turns about the center of what the first one made. + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"moveH": 100})), transform(json!({"rotate": 90}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + let c = map * Point::new(20.0, 20.0); + assert!((c.x - 120.0).abs() < 1e-9 && (c.y - 20.0).abs() < 1e-9, "{c:?}"); + assert_eq!(crate::stage_map(&effects, &[1, 2], &PathData::default()), None); } /// An SVG filter keeps its options: it paints in its own user space. diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 99b8e1ecdd..c6aed0a644 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -43,6 +43,7 @@ pub(crate) mod graph; mod halftone; pub mod help; pub mod importmatches; +mod info; pub(crate) mod inline; mod interactivity; mod intertwine; @@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(docmenu::specs()); v.extend(variablecmds::specs()); v.extend(docinfo::specs()); + v.extend(info::specs()); v.extend(panelcmds::specs()); v.extend(artboardalign::specs()); v.extend(buildcmds::specs()); @@ -828,6 +830,7 @@ pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys}; +pub use select::SIMILAR; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see @@ -896,6 +899,9 @@ pub use brushsym::reset_transformation_changes; /// Data window reads files by it too). pub use variablecmds::utf16_order_without_mark; +/// The Variables panel's sort (`variable.sort`) and its rows in that order. +pub use variablecmds::{VariableSort, VariableSortKey, variable_order}; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index cba512cd18..d5f241b4ee 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -9,7 +9,7 @@ mod tests; mod text; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, SubPath}; use vectorcraft_pathops::addons as po; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Simplify Anchors (Keep Shape)…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts", + "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step when a path changes; open ends stay; from the selection, locked and hidden paths are left out, and named by `ids`, the paths of the objects, groups and layers it names are taken even when locked or hidden, but not those on a locked artboard; a changed path loses its selected anchors and segments → {ids, before, after} anchor counts", has_selection, simplify ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Deep Ungroup…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", + "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step when a group goes; each object keeps the opacity of the groups it was in; from the selection, locked and hidden groups stay whole; named by `ids`, the groups it names and the groups in the groups and layers it names are ungrouped even when locked or hidden (a named layer stays), but not those on a locked artboard, and their objects become hidden or locked when a group was; locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", has_selection, deep_ungroup ), @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Rejoin Path Pieces…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}", + "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step when a path is made; from the selection, locked and hidden paths are left out, and named by `ids`, they are taken, but not those on a locked artboard; only pieces alike in being hidden (by themselves or a group or layer above them) and in being locked join; locked and hidden paths made are not selected → {ids, joined (paths made), removed (pieces merged into them)}", has_selection, rejoin ), @@ -91,9 +91,28 @@ pub fn specs() -> Vec { v } -/// The paths (not guides) under `roots`, compound path members included; locked and hidden -/// paths, and those in locked or hidden groups, stay out. -fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { +/// Whether a request names its objects with `ids` (present and not null) rather than taking the +/// selection. +fn named(p: &Value) -> bool { + p.get("ids").is_some_and(|v| !v.is_null()) +} + +/// Keep the items of `items` whose object (`id`) a command acts on: when `named`, every one not on +/// a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer) +/// included; else only those that can be edited. +fn retain_targets(d: &Document, items: &mut Vec, named: bool, id: impl Fn(&T) -> NodeId) { + if named { + let locks = d.locked_artboards(); + items.retain(|t| !locks.holds_id(id(t))); + } else { + let editable = d.editable(); + items.retain(|t| editable(id(t))); + } +} + +/// The paths (not guides) under `roots`, compound path members included, that a command acts on +/// ([`retain_targets`]). +fn paths_under(d: &Document, roots: &[NodeId], named: bool) -> Vec { let mut out = vec![]; for n in roots.iter().filter_map(|id| d.node(*id)) { n.walk(&mut |c| { @@ -102,8 +121,7 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { } }); } - let editable = d.editable(); - out.retain(|id| editable(*id)); + retain_targets(d, &mut out, named, |id| *id); out } @@ -116,7 +134,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let protect = switch_or(p, "protectCorners", false, C)?; let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - let ids = paths_under(d, &roots); + let ids = paths_under(d, &roots, named(p)); let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum(); if before > MAX_ANCHORS { @@ -258,10 +276,11 @@ fn ungroupable(n: &Node, clips: bool) -> bool { } /// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts -/// the groups. Members take on the opacity of the groups they were in, and a clipping path -/// becomes a plain path. A locked or hidden group stays whole. -fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { - if !ungroupable(&n, clips) || n.locked || !n.visible { +/// the groups. Members take on the opacity of the groups they were in, are hidden when one of them +/// was hidden and locked when one of them was locked, and a clipping path becomes a plain path. A +/// locked or hidden group stays whole unless `named`. +fn flatten(n: Node, clips: bool, named: bool, out: &mut Vec, count: &mut usize) { + if !ungroupable(&n, clips) || (!named && (n.locked || !n.visible)) { out.push(n); return; } @@ -276,26 +295,57 @@ fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { *clipping = false; } c.opacity *= n.opacity; - flatten(c, clips, out, count); + c.visible &= n.visible; + c.locked |= n.locked; + flatten(c, clips, named, out, count); } } +/// The outermost groups Deep Ungroup takes apart in layer `n`, its sublayers included, in paint +/// order, leaving out those on a locked artboard. +fn groups_in_layer(d: &Document, n: &Node, clips: bool, out: &mut Vec) { + let locks = d.locked_artboards(); + fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + visit(c, clips, locks, out); + } else if ungroupable(c, clips) && !locks.holds_id(c.id) { + out.push(c.id); + } + } + } + visit(n, clips, &locks, out); +} + fn deep_ungroup(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.deepUngroup"; let clips = switch_or(p, "includeClipGroups", false, C)?; - let roots = checked_ids_or_selection(s, p, C)?; + let named = named(p); + let mut roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - // Locked and hidden objects, and those in locked or hidden groups, stay as they are. - let roots: Vec = { - let editable = d.editable(); - roots.into_iter().filter(|id| editable(*id)).collect() - }; + // From the selection, locked and hidden objects, and those in locked or hidden groups, stay as + // they are; named, only those on a locked artboard do. + retain_targets(d, &mut roots, named, |id| *id); + // A named layer stays: the groups in it, and in its sublayers, are taken apart. + let mut groups = vec![]; + for id in roots { + match d.node(id) { + Some(n) if named && n.is_layer() => { + let at = groups.len(); + groups_in_layer(d, n, clips, &mut groups); + if groups.len() == at { + groups.push(id); + } + } + _ => groups.push(id), + } + } let (mut items, mut selected, mut count) = (vec![], vec![], 0); - for id in &roots { + for id in &groups { match d.node(*id).filter(|n| ungroupable(n, clips)) { Some(n) => { let mut out = vec![]; - flatten(n.clone(), clips, &mut out, &mut count); + flatten(n.clone(), clips, named, &mut out, &mut count); selected.extend(out.iter().map(|n| n.id)); items.push((*id, out)); } @@ -304,7 +354,7 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { } // With no group to take apart, the document, the selection and the undo history stay as they // are, and the reply lists the selection. - if items.is_empty() { + if count == 0 { selected = s.doc()?.selection.objects.to_vec(); } else { selected = s.edit("Deep Ungroup", move |d, sel| { @@ -319,26 +369,39 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) } +/// One path Rejoin Path Pieces takes: its id, its subpath, and whether it and every group and layer +/// above it are visible, and whether they are all unlocked. +type Piece = (NodeId, SubPath, (bool, bool)); + /// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order -/// (compound path members, clipping paths and guides stay out). -fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> { - fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) { +/// (compound path members, clipping paths and guides stay out; [`retain_targets`]). +fn pieces(d: &Document, roots: &[NodeId], named: bool) -> Vec { + fn visit(n: &Node, above: (bool, bool), out: &mut Vec) { + let state = (above.0 && n.visible, above.1 && !n.locked); match &n.kind { NodeKind::Path { path, guide: false, clipping: false, .. } => { if let [sp] = path.subpaths.as_slice() && !sp.closed { - out.push((n.id, sp.clone())); + out.push((n.id, sp.clone(), state)); } } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, state, out)), _ => {} } } let mut out = vec![]; - roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); - let editable = d.editable(); - out.retain(|(id, _)| editable(*id)); + for (id, n) in roots.iter().filter_map(|id| Some((*id, d.node(*id)?))) { + // The groups and layers above the root, and their state. + let above = d.ancestry(id).unwrap_or_default(); + let above = above.split_last().map_or(&[][..], |(_, up)| up); + if above.last().and_then(|i| d.node(*i)).is_some_and(|parent| matches!(parent.kind, NodeKind::Compound { .. })) { + continue; + } + let state = above.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), a| (v && a.visible, u && !a.locked)); + visit(n, state, &mut out); + } + retain_targets(d, &mut out, named, |piece| piece.0); out } @@ -349,18 +412,26 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`gap` must be from 0 to 6 pt")); } let roots = checked_ids_or_selection(s, p, C)?; - let found = pieces(&s.doc()?.doc, &roots); + let found = pieces(&s.doc()?.doc, &roots, named(p)); if found.len() > MAX_OBJECTS { return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into())); } - let subpaths: Vec = found.iter().map(|(_, sp)| sp.clone()).collect(); - // Each chain becomes its topmost piece (the last in paint order); the others go. + // Only pieces alike in being shown and in being unlocked join, so that a joined path neither + // shows hidden art nor hides shown art, nor locks or unlocks a piece. let (mut kept, mut gone) = (vec![], vec![]); - for j in po::rejoin(&subpaths, gap) { - let ids: Vec = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect(); - let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue }; - gone.extend(ids.into_iter().filter(|id| *id != top)); - kept.push((top, j.path)); + for state in [(true, true), (true, false), (false, true), (false, false)] { + let part: Vec<&Piece> = found.iter().filter(|f| f.2 == state).collect(); + if part.is_empty() { + continue; + } + let subpaths: Vec = part.iter().map(|f| f.1.clone()).collect(); + // Each chain becomes its topmost piece (the last in paint order); the others go. + for j in po::rejoin(&subpaths, gap) { + let ids: Vec = j.members.iter().filter_map(|&m| part.get(m).map(|f| f.0)).collect(); + let Some(top) = j.members.iter().max().and_then(|&m| part.get(m).map(|f| f.0)) else { continue }; + gone.extend(ids.into_iter().filter(|id| *id != top)); + kept.push((top, j.path)); + } } if !kept.is_empty() { let ids: Vec = kept.iter().map(|k| k.0).collect(); @@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { set_path(d, *id, PathData::single(sp.clone())); } d.remove_many(&gone)?; - sel.set(ids); + // Locked and hidden paths, and those in locked or hidden groups, are not selected. + let editable = d.editable(); + sel.set(ids.into_iter().filter(|id| editable(*id))); Ok(()) })?; } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 4d4ed6461b..e1a50b928b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, LayerColor, Node, NodeId, NodeKind}; use vectorcraft_geom::{FillRule, Rect}; use crate::Session; @@ -123,18 +123,100 @@ fn simplify_leaves_locked_and_hidden_paths_alone() { assert_eq!(r["before"], 16); assert!(anchors(&s, ids[0]) < 16); assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - // Named by `ids`, or in a locked group, a path stays too, with no undo step. - let steps = undo_steps(&s); - s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap(); - assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - assert_eq!(undo_steps(&s), steps); + // In a selected locked group, a path stays too, with no undo step. change(&mut s, ids[1], |n| n.locked = false); change(&mut s, g, |n| n.locked = true); let steps = undo_steps(&s); - let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0))); assert_eq!(anchors(&s, ids[1]), 16); assert_eq!(undo_steps(&s), steps); + // Named by `ids`, the locked group's paths, its hidden one included, are simplified. + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(r["ids"], json!([ids[0].0, ids[1].0, ids[2].0])); + assert!(anchors(&s, ids[1]) < 16 && anchors(&s, ids[2]) < 16); + assert_eq!(undo_steps(&s), steps + 1); +} + +/// A path of 16 anchors at (`x`, `y`): an ellipse with anchors added twice. +fn ellipse16(s: &mut Session, x: f64, y: f64) -> NodeId { + let e = id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()); + select(s, &[e]); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + e +} + +/// Simplify Anchors takes the locked and hidden paths `ids` names, those in the locked or hidden +/// groups and layers it names (their locked and hidden members too) and a hidden compound path's +/// member, as one undo step, without selecting them; from the selection it leaves them alone, and a +/// path on a locked artboard stays even when named. +#[test] +fn simplify_named_locked_and_hidden_paths() { + let mut s = session(); + let locked = ellipse16(&mut s, 10.0, 10.0); + let hidden = ellipse16(&mut s, 70.0, 10.0); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| n.visible = false); + // A locked group with a hidden member, and a hidden group with a locked member. + let in_locked = ellipse16(&mut s, 130.0, 10.0); + let locked_group = wrap(&mut s, in_locked, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, locked_group, |n| n.locked = true); + change(&mut s, in_locked, |n| n.visible = false); + let in_hidden = ellipse16(&mut s, 190.0, 10.0); + let hidden_group = wrap(&mut s, in_hidden, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, hidden_group, |n| n.visible = false); + change(&mut s, in_hidden, |n| n.locked = true); + // A member of a hidden compound path. + let member = ellipse16(&mut s, 250.0, 10.0); + let compound = wrap(&mut s, member, |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + change(&mut s, compound, |n| n.visible = false); + // A path in a locked layer, named itself. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let in_layer = ellipse16(&mut s, 10.0, 100.0); + let on_board = ellipse16(&mut s, 10.0, 200.0); + change(&mut s, layer, |n| n.locked = true); + let all = [locked, hidden, in_locked, in_hidden, member, in_layer]; + select(&mut s, &[locked, hidden, locked_group, hidden_group, compound]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([]), "from the selection, none is taken"); + assert_eq!(undo_steps(&s), steps); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + select(&mut s, &[]); + s.edit("Select Anchors", |_, sel| { + sel.anchors.insert(locked, [(0, 1)].into()); + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0, hidden.0, locked_group.0, hidden_group.0, member.0, in_layer.0]})).unwrap(); + assert_eq!(r["ids"], json!(all.iter().map(|i| i.0).collect::>())); + assert_eq!(r["before"], 16 * 6); + assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::())); + assert!(all.iter().all(|id| anchors(&s, *id) < 16), "{r}"); + assert_eq!(undo_steps(&s), steps + 1); + let sel = &s.doc().unwrap().selection; + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "nothing is selected, and the changed path's anchors are not"); + let n = node(&s, in_hidden).unwrap(); + assert!(n.locked && n.visible && !node(&s, hidden_group).unwrap().visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) < 16)); + // Nothing left to remove: no undo step. + let steps = undo_steps(&s); + s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps); + // On a locked artboard, a named path stays as it is. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ids"].clone(), anchors(&s, on_board), undo_steps(&s)), (json!([]), 16, steps)); } /// Each kind: what makes the issue in a fresh document (beside a clean rectangle). @@ -610,14 +692,15 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible); assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked); assert_eq!(s.doc().unwrap().selection.objects, [a]); - // Named by `ids`, a locked group stays as it is, with no undo step. + // Named by `ids`, a locked group is taken apart as one undo step; its member becomes locked + // and is not selected. let steps = undo_steps(&s); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap(); - assert_eq!(r["ungrouped"], 0); - assert!(node(&s, locked).is_some()); - assert_eq!(undo_steps(&s), steps); + assert_eq!(r["ungrouped"], 1); + assert!(node(&s, locked).is_none() && node(&s, c).unwrap().locked); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(r["ids"], json!([])); // With nothing ungrouped, the selection stays and the reply lists it. - assert_eq!(r["ids"], json!([a.0])); let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); select(&mut s, &[a]); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap(); @@ -625,6 +708,108 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert_eq!(s.doc().unwrap().selection.objects, [a]); } +/// Put `ids` inside a new container that `kind` makes of its members. +fn wrap_all(s: &mut Session, ids: &[NodeId], kind: fn(Vec>) -> NodeKind) -> NodeId { + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(ids, |c| Node::new(w, kind(c)))?) + }) + .unwrap() +} + +fn plain_group(c: Vec>) -> NodeKind { + NodeKind::Group { children: c, clip: false } +} + +fn sublayer(c: Vec>) -> NodeKind { + NodeKind::Layer { color: LayerColor::Preset(0), template: false, printable: true, children: c, clip: false, preview: true, dim_images: None } +} + +/// Named by `ids`, Deep Ungroup takes apart locked and hidden groups, those nested in a group it +/// names and those in a layer it names (its sublayers included, the layer staying), as one undo +/// step; their members become locked or hidden as the groups were, keep the groups' opacity and are +/// not selected. From the selection those groups stay whole, as do a clipping group and a group on a +/// locked artboard even when named. +#[test] +fn deep_ungroup_named_locked_and_hidden_groups() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = wrap_all(&mut s, &[a], plain_group); + let hidden = wrap_all(&mut s, &[b], plain_group); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| { + n.visible = false; + n.opacity = 0.5; + }); + select(&mut s, &[locked, hidden]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, they stay whole"); + assert!(node(&s, locked).is_some() && node(&s, hidden).is_some()); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0, hidden.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(2), json!([]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, locked).is_none() && node(&s, hidden).is_none()); + let (na, nb) = (node(&s, a).unwrap(), node(&s, b).unwrap()); + assert!(na.locked && na.visible && !nb.visible && !nb.locked); + assert_eq!(nb.opacity, 0.5); + assert!(s.doc().unwrap().selection.objects.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, locked).is_some() && !node(&s, a).unwrap().locked && node(&s, b).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, locked).is_none() && node(&s, a).unwrap().locked); + // Locked and hidden groups nested in a named group that can be edited. + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner_locked = wrap_all(&mut s, &[e], plain_group); + let inner_hidden = wrap_all(&mut s, &[f], plain_group); + change(&mut s, inner_locked, |n| n.locked = true); + change(&mut s, inner_hidden, |n| n.visible = false); + let outer = wrap_all(&mut s, &[c, inner_locked, inner_hidden], plain_group); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [outer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(3), json!([c.0]))); + assert!(node(&s, e).unwrap().locked && !node(&s, f).unwrap().visible); + assert_eq!(s.doc().unwrap().selection.objects, [c]); + // In a named layer and its sublayer; the layers and a clipping group stay. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let g = rect(&mut s, 10.0, 100.0, 20.0, 20.0); + let h = rect(&mut s, 40.0, 100.0, 20.0, 20.0); + let layer_locked = wrap_all(&mut s, &[g], plain_group); + change(&mut s, layer_locked, |n| n.locked = true); + let layer_hidden = wrap_all(&mut s, &[h], plain_group); + change(&mut s, layer_hidden, |n| n.visible = false); + let sub = wrap_all(&mut s, &[layer_hidden], sublayer); + let clipped = rect(&mut s, 100.0, 100.0, 10.0, 10.0); + let clip = rect(&mut s, 95.0, 95.0, 30.0, 30.0); + select(&mut s, &[clipped, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clip_group = s.doc().unwrap().selection.objects[0]; + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(2), steps + 1)); + assert!(node(&s, layer_locked).is_none() && node(&s, layer_hidden).is_none()); + assert!(node(&s, layer).is_some() && node(&s, clip_group).is_some()); + assert!(node(&s, g).unwrap().locked && !node(&s, h).unwrap().visible); + assert_eq!(s.doc().unwrap().doc.parent_of(h), Some(sub)); + // On a locked artboard, a named group stays whole, with the selection as it was. + let k = rect(&mut s, 10.0, 200.0, 20.0, 20.0); + let on_board = wrap_all(&mut s, &[k], plain_group); + let off_board = rect(&mut s, 500.0, 10.0, 20.0, 20.0); + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + select(&mut s, &[off_board]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone(), undo_steps(&s)), (json!(0), json!([off_board.0]), steps)); + assert!(node(&s, on_board).is_some()); + assert_eq!(s.doc().unwrap().selection.objects, [off_board]); +} + #[test] fn rejoin_joins_pieces_within_the_gap_only() { let mut s = session(); @@ -646,3 +831,74 @@ fn rejoin_joins_pieces_within_the_gap_only() { assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}"); } } + +/// Named by `ids`, Rejoin Path Pieces takes locked and hidden pieces (in a locked group or layer +/// too) and joins only pieces alike in being hidden and in being locked, whichever is topmost, as one +/// undo step; a locked or hidden path it makes is not selected. From the selection it takes only +/// pieces that can be edited, and compound path members and pieces on a locked artboard stay out. +#[test] +fn rejoin_named_locked_and_hidden_pieces() { + let mut s = session(); + let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]); + let b = line(&mut s, &[(50.05, 10.0), (90.0, 10.0)]); + let g = wrap_all(&mut s, &[a, b], plain_group); + change(&mut s, g, |n| n.locked = true); + select(&mut s, &[g]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, none is taken"); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [g.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([b.0]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked path is not selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, a).is_some() && anchors(&s, b) == 2); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + // A shown piece and two hidden ones, the hidden on top: only the hidden pair joins. + let c = line(&mut s, &[(10.0, 40.0), (50.0, 40.0)]); + let e = line(&mut s, &[(50.05, 40.0), (90.0, 40.0)]); + let h = line(&mut s, &[(90.05, 40.0), (130.0, 40.0)]); + change(&mut s, e, |n| n.visible = false); + change(&mut s, h, |n| n.visible = false); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [c.0, e.0, h.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([h.0]))); + assert!(node(&s, e).is_none() && anchors(&s, c) == 2 && anchors(&s, h) == 3); + assert!(!node(&s, h).unwrap().visible && node(&s, c).unwrap().visible); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden path is not selected"); + // An unlocked piece under a locked one, and a hidden piece under a shown one: none joins. + let k = line(&mut s, &[(10.0, 70.0), (50.0, 70.0)]); + let m = line(&mut s, &[(50.05, 70.0), (90.0, 70.0)]); + let n = line(&mut s, &[(10.0, 100.0), (50.0, 100.0)]); + let o = line(&mut s, &[(50.05, 100.0), (90.0, 100.0)]); + change(&mut s, m, |x| x.locked = true); + change(&mut s, n, |x| x.visible = false); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [k.0, m.0, n.0, o.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), undo_steps(&s)), (json!(0), json!(0), steps)); + assert!([k, m, n, o].iter().all(|id| anchors(&s, *id) == 2)); + // Named pieces in a locked layer join; members of a compound path stay out. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let p = line(&mut s, &[(10.0, 130.0), (50.0, 130.0)]); + let q = line(&mut s, &[(50.05, 130.0), (90.0, 130.0)]); + let u = line(&mut s, &[(10.0, 160.0), (50.0, 160.0)]); + let v = line(&mut s, &[(50.05, 160.0), (90.0, 160.0)]); + wrap_all(&mut s, &[u, v], |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + let w = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)]); + let x = line(&mut s, &[(50.05, 200.0), (90.0, 200.0)]); + change(&mut s, layer, |l| l.locked = true); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [p.0, q.0, u.0, v.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["ids"].clone()), (json!(1), json!([q.0]))); + assert!(node(&s, p).is_none() && anchors(&s, u) == 2 && anchors(&s, v) == 2); + // On a locked artboard, named pieces stay as they are. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [w.0, x.0]})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps)); + assert!(node(&s, w).is_some() && node(&s, x).is_some()); +} diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 10a00b1b45..d0c5447c42 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin) } +/// A glyph of a character: its byte, whether it stands for characters (a list marker does not), +/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs). +type GlyphKey = (usize, bool, usize); + +/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an +/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without +/// a drawn glyph. +fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> { + let (gi, g) = (lay.glyphs.iter().enumerate()) + .filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite()) + .min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?; + let kind = g.len != 0; + let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count(); + Some(((g.byte, kind, nth), g.origin)) +} + +/// The pen position of the glyph `key` names in `lay`. None without one. +fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option { + let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?; + (g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin) +} + /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the /// object's own text space, so its transform stays). Type on a Path Options are dropped, and @@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { fn released_text(t: &TextObject) -> Option { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); - // Where the first character sat. Text that didn't fit on the path has no glyph there: it - // starts at the start bracket. - let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| { + // Where the first drawn character sat; characters before an open path's start come before it + // on the line. Text that didn't fit on the path has no glyph there: it starts at the start + // bracket. + let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| { let ap = ArcPath::new(path); let (from, _) = t.kind.path_span()?; let (p, _) = ap.at(from * ap.len()); - (p.x.is_finite() && p.y.is_finite()).then_some(p) + (p.x.is_finite() && p.y.is_finite()).then_some((None, p)) })?; let mut out = t.clone(); // Point type has no brackets to fit: it keeps the sizes the path drew it at. @@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option { out.path_spacing = 0.0; // Point type is in no thread: its first paragraph starts in it. out.first_para_continues = false; - // Where the point type puts its own first character; the origin moves so it lands at `at`, - // whatever the paragraph's alignment and direction. - let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO); + // Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the + // paragraph's alignment and direction. + let own_lay = vectorcraft_text::layout(db, &out); + let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO); let shift = at - own; if !(shift.x.is_finite() && shift.y.is_finite()) { return None; diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index e597525c97..a8518719be 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -482,6 +482,44 @@ mod release { } } + /// Type that starts before its open path: the first drawn character stays where it was drawn, + /// and the characters that were not drawn come before it on the line. + #[test] + fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start"); + s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap(); + let (byte, drawn) = (first.byte, before.xf * first.origin); + assert!(byte > 0, "some characters are before the path"); + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + assert_eq!(after.kind, TextKind::Point); + assert_eq!(after.plain_text(), before.plain_text()); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap(); + assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin); + let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap(); + assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it"); + // An All Caps ß is two glyphs: with the first before the path's start, the second stays + // where it was drawn. + let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance; + s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let drawn = before.xf * lay.glyphs[1].origin; + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap(); + assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin); + } + #[test] fn flipped_type_releases_its_path_as_it_runs() { let mut s = session(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index b9b517ae85..256e585923 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Add Effect", [], None, - "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}", + "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}", has_doc, super::effectcmd::apply ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -183,14 +183,21 @@ pub(crate) struct ActiveItem { impl Session { /// The method returns explicit target parameters and the selected appearance effect list. pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { - let mut ids = appearance_targets(self, params).ok()?; + let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); - let node = self.active()?.doc.node(*ids.get(anchor)?)?; + // Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the + // Appearance panel's active row is a row of the first selected object's. + let of = match item { + ItemTarget::Item { explicit: false, .. } => None, + _ => design_reference(self, params, "effect.apply").ok()?, + }; + let own = *ids.get(anchor)?; + let node = self.active()?.doc.node(of.unwrap_or(own))?; let at = item.effects_item(&node.appearance, "effect.apply").ok()?; let effects = node.appearance.effects_at(at)?; ids.swap(0, anchor); - let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let ids: Vec = with_reference(of, &ids).into_iter().map(|id| id.0).collect(); let aligned = match item { ItemTarget::Item { aligned, .. } => aligned, ItemTarget::Top => aligned(params, "effect.apply").ok()?, @@ -253,6 +260,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), + // A row of a graph design's art in a series stands for nothing: the commands leave that art out. + None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => { + Ok(ItemTarget::Top) + } None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })), } } @@ -300,6 +311,21 @@ impl ItemTarget { .collect() } + /// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]), + /// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without + /// an equal item is left out (`None`). + pub(crate) fn per_target_of(self, s: &Session, of: Option, ids: &[NodeId]) -> Vec> { + let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) }; + let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) }; + let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) }; + ids.iter() + .map(|id| { + let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?; + Some(ItemTarget::Item { index, explicit: true, aligned: true }) + }) + .collect() + } + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of /// is: the first selected object (or the compound path holding it) for the active item, which /// need not paint first; the first target otherwise, or when that object is not among them. @@ -384,6 +410,41 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id } } +/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the +/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A +/// command reads it after its own target checks. +pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result> { + if !aligned(p, cmd)? { + return Ok(None); + } + let ids = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id))) +} + +/// The [`design_reference`] of an edit aimed at `item`: none for the Appearance panel's active row, which is a row of the +/// first selected object's stack. +pub(crate) fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + +/// `ids` after `of` (see [`design_reference`]), when there is one. +pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { + of.into_iter().chain(ids.iter().copied()).collect() +} + +/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`]) +/// when there is one: a row a target has no equal of is left out of that target. +pub(crate) fn rows_per_target(d: &Document, of: Option, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match of { + Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(), + None => items_per_target(d, ids, idx, aligned), + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -424,22 +485,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a /// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series /// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets -/// are all such art is refused. -pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { +/// are all such art is refused as an error of `cmd`. +pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = appearance_targets(s, p)?; - without_design_art(&s.doc()?.doc, ids, "appearance") + without_design_art(&s.doc()?.doc, ids, cmd) } /// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. -pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { +pub(crate) fn without_design_art(d: &Document, ids: Vec, cmd: &str) -> Result> { let given = ids.len(); - ids.retain(|id| !super::graph::is_design_art(d, *id)); + let ids = drop_design_art(d, ids); if given > 0 && ids.is_empty() { return Err(bad(cmd, DESIGN_ART)); } Ok(ids) } +/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an +/// error. +pub(crate) fn drop_design_art(d: &Document, mut ids: Vec) -> Vec { + ids.retain(|id| !super::graph::is_design_art(d, *id)); + ids +} + +/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that +/// [`super::graph::is_design_art`] accepts. +pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in roots { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |m| { + if m.extras().series_index.is_some() { + out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c))); + } + }); + } + out +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -476,15 +559,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { /// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the /// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. -pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = edit_targets(s, p)?; +pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = edit_targets(s, p, cmd)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. -pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = edit_targets(s, p)?; +pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let own = edit_targets(s, p, cmd)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -513,7 +596,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool { /// [`whole_targets`] of `cmd`, which needs at least one. fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { - let ids = whole_targets(s, p)?; + let ids = whole_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -522,7 +605,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result Result> { - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -536,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not /// an edit, and when `f` leaves every target as it was, the document and its undo history stay as /// they are. +/// +/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which +/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to +/// it, a target without one is left as it is, and no target with one is an error. Returns the +/// targets `f` was aimed at, in the order of `ids`. +#[allow(clippy::too_many_arguments)] pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, -) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| { +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| { let series = capture_series_changes(before, d, edited); // The rows `f` edited are kept by `edit_items_then`. let labels = super::graph::capture_label_changes(before, d, edited, false); @@ -562,17 +652,29 @@ pub(crate) fn edit_items_then( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, -) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } +) -> Result> { + let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone())); + let per = match (item, art) { + // Checked even when the art is the only object given, which leaves `ids` empty. + (ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => { + item.resolve(&art, fill, cmd)?; + let per = item.per_target_of(s, of, ids); + if per.iter().all(Option::is_none) { + return Err(bad(cmd, format!("no appearance item {index}"))); + } + per + } + _ if ids.is_empty() => return Ok(Vec::new()), + _ => item.per_target(s, ids), + }; + let aimed: Vec = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect(); let before = s.doc()?.doc.clone(); - let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); // The objects whose rows `f` edited: on type, no row means its characters. @@ -591,7 +693,13 @@ pub(crate) fn edit_items_then( let labels = super::graph::capture_label_rows(d, &rows); Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; - Ok(()) + Ok(aimed) +} + +/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it +/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is. +pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> { + ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?))) } /// How many of `ids` differ in `d` from `before`. @@ -600,17 +708,23 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or -/// null is an error of command `cmd`. +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than +/// true, false or null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; - let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; - Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + let holder = match reference_of(s, p, item, cmd)? { + Some(of) => Some(of), + None => { + let ids = drop_design_art(d, appearance_targets(s, p)?); + ids.get(item.anchor(s, &ids)).copied() + } + }; + Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. @@ -1032,7 +1146,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, "appearance.setItem")?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1040,7 +1154,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?); + let of = design_reference(s, p, "appearance.setItem")?; + let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?); super::overprint::edit_counted(s, "Appearance", |d| { let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { @@ -1127,7 +1242,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?); + let of = design_reference(s, p, "appearance.removeItem")?; + let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?); super::overprint::edit_counted(s, "Remove Item", |d| { for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { @@ -1168,12 +1284,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut first_at = None; let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); let mut active_at = None; - // Aligned rows of a mixed selection are copied right above themselves in each object. - if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { + // Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art, + // which is left out, go to `to` in an object whose stack is the same as the art's. + let of = design_reference(s, p, C)?; + if aligned(p, C)? + && to.is_some() + && (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids)))) + { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, C)?); + // With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist. + let per = + if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) }; s.edit("Duplicate Item", |d, _| { let mut any = false; // Copies that became the topmost visible row of their kind: the series stores their paint even when the object @@ -1280,15 +1403,19 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = edit_targets(s, p)?; + let ids = edit_targets(s, p, C)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The Contents row's place counts the rows below it, which isn't one place in several different stacks either. + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { + return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); + } return move_contents(s, &ids, to); } let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. - if aligned && appearances_differ(&s.doc()?.doc, &ids) { + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); } if p.get("indices").is_some() { diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 16ab1c76d4..34becd7c02 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, + BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit }, ]; /// Built-in library `id`, made once. @@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brus Brush { name: name.into(), kind } } +/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers +/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections. +fn tech_circuit() -> BrushLibrary { + let pad = |x: f64| { + black(polygon(&[ + (x - 4.0, -2.0), + (x - 2.0, -4.0), + (x + 2.0, -4.0), + (x + 4.0, -2.0), + (x + 4.0, 2.0), + (x + 2.0, 4.0), + (x - 2.0, 4.0), + (x - 4.0, 2.0), + ])) + }; + // 45-degree jogs lift the conductor to a parallel run and bring it back. + let trace = group(vec![ + black(polygon(&[ + (4.0, -1.0), + (23.0, -1.0), + (33.0, -11.0), + (61.0, -11.0), + (71.0, -1.0), + (96.0, -1.0), + (96.0, 1.0), + (70.0, 1.0), + (60.0, -9.0), + (34.0, -9.0), + (24.0, 1.0), + (4.0, 1.0), + ])), + pad(4.0), + pad(96.0), + ]); + let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect()); + let terminals = group(vec![ + // Overlap the ring's rim without filling the test point's hole. + black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))), + black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))), + pad(6.0), + ring(Point::new(17.0, 0.0), 3.0, 1.4), + ]); + built( + "Tech Circuit", + Document::new(100.0, 100.0), + vec![ + art("Circuit Trace", trace, Colorization::Tints), + border("Bus Lines", bus, None, PatternFit::Stretch), + border("Terminal Nodes", terminals, None, PatternFit::Stretch), + ], + ) +} + /// An invisible rectangle that fixes a tile's width. fn spacer(r: Rect) -> Node { Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1046cada1c..db6e4e28a1 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,8 +19,8 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, - str_param, switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, named_ids, number_or, ok, str_param, + switch_or, }; use crate::EngineError; @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Paintbrush", [], None, - "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}", + "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, a `style` other than \"pencil\" or \"brush\", a `brush` for a path with no stroke (a continued or redrawn path without one, or a new filled \"pencil\" path while the stroke is None) and an unknown key are errors and nothing is drawn; the path and its brush are one undo step → {id}", has_doc, brush_freehand ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 7fd8c9aa9a..89f3893b24 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -1,7 +1,6 @@ use super::{ AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value, - anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, - str_param, + anchor, bad, brush, check_numbers, find_command, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, str_param, }; // ---------- brushes ---------- @@ -729,9 +728,10 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&out) })) } -/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws -/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style` -/// other than "pencil" or "brush", is an error before anything is drawn. +/// Paints a `path.freehand` stroke with the brush `brush` names, in one undo step with the path. +/// A `brush` left out or null draws the plain path; a `brush` that is not a string or names no +/// brush in the document, a `style` other than "pencil" or "brush", and a `brush` for a path with +/// no stroke are errors before anything is drawn. pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.freehand"; let name = match crate::cmd::text_param(p, "brush", C)? { @@ -745,13 +745,6 @@ pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))), } let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = crate::cmd::run_checked(s, spec, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) + let checked = crate::schemas::check(spec, p, s.replaying())?; + crate::cmd::draw2::freehand_brushed(s, checked.as_ref().unwrap_or(p), name.as_deref()) } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f694a2b144..66c9ec8fc4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces: each face reached has its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the place of its topmost fill (none leaves one fill of none), and its strokes and its own opacity stay as they are; when every face it reaches already has only that one fill, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached takes the paint and width in its topmost stroke that is shown, or in a new stroke above the others when it has none; when every edge it reaches already has the paint and the width there, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached has its strokes replaced by one stroke with the paint and width, shown, fully opaque and blending normally, in the place of its topmost stroke and with the dashes, caps and arrowheads of its topmost shown stroke (a new stroke above the fills when it has none; none leaves one stroke of none), and its fills stay as they are; when every edge it reaches already has only that one stroke (at any width for none), nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", has_doc, lp_stroke_edge ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\" (abutting with fills, not mode logo: a \"Seam Fills\" group under the paths holds 1.5 px strokes along the edges two filled paths share, in the color of the lower path, so that no light hairline shows along those edges; the paths are not changed), ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 8dcd453650..6784f36294 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -525,16 +525,17 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { Some(gid) => { let (fid, targets) = targets_in(&s.doc()?.doc, gid)?; let count = targets.len(); - // Faces that already have the paint stay as they are: when every face has it, nothing - // changes and no undo step is recorded. + // Faces that already have only the fill the bucket would leave ([`bucket_keeps`]) stay as + // they are: when every face does, nothing changes and no undo step is recorded. let d = &s.doc()?.doc; - let unpainted: Vec = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); + let unpainted: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| !bucket_keeps(&f.appearance, true, &paint, 0.0))).collect(); if unpainted.is_empty() { return Ok(json!({ "group": gid.0, "face": fid.0, "count": count })); } s.edit("Live Paint Bucket", |d, _| { for target in unpainted { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })? @@ -544,7 +545,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let (fid, targets) = targets_in(d, gid)?; let count = targets.len(); for target in targets { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })?, @@ -580,18 +581,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { .collect(), }; let count = targets.len(); - // An edge whose shown stroke already has the paint and the width (or that has no shown stroke - // and gets none) stays as it is; when every edge does, nothing changes and no undo step is - // recorded. - let changed: Vec = targets - .into_iter() - .filter(|t| { - d.node(*t).is_none_or(|e| match shown_stroke(e) { - Some(st) => !(st.paint == paint && st.width == width), - None => !paint.is_none(), - }) - }) - .collect(); + // An edge that already has only the stroke the bucket would leave ([`bucket_keeps`]) stays + // as it is; when every edge does, nothing changes and no undo step is recorded. + let changed: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|e| !bucket_keeps(&e.appearance, false, &paint, width))).collect(); (eid, count, changed) }; if changed.is_empty() { @@ -599,14 +592,7 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { } s.edit("Live Paint Bucket", |d, _| { for target in &changed { - let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?; - match shown_stroke_mut(e) { - Some(st) => { - st.paint = paint.clone(); - st.width = width; - } - None => e.appearance.items.push(super::AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))), - } + bucket_paint(&mut d.node_mut(*target).ok_or(EngineError::NoNode(*target))?.appearance, false, &paint, width); } Ok(()) })?; @@ -614,22 +600,76 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "edge": eid.0, "count": count })) } -/// The stroke `livePaint.strokeEdge` paints on Live Paint edge `e`: its topmost stroke that is -/// shown. None when it has no stroke or every stroke is hidden; the command then adds a stroke -/// above the others. -fn shown_stroke(e: &Node) -> Option<&super::StrokeLayer> { - e.appearance.items.iter().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Whether the Live Paint Bucket leaves appearance `a` as it is when it paints its fills (`fill`) +/// or strokes with `paint` (a stroke at `width`): it has exactly one row of that kind, shown, fully +/// opaque, blending normally and with the paint (a stroke also with the width, unless the paint is +/// none), or it has no row of that kind and the paint is none. +fn bucket_keeps(a: &Appearance, fill: bool, paint: &super::Paint, width: f64) -> bool { + let mut rows = a.items.iter().filter(|i| i.is_fill() == fill); + match (rows.next(), rows.next()) { + (None, _) => paint.is_none(), + (Some(row), None) => { + row.visible() + && row.opacity() == 1.0 + && row.blend() == vectorcraft_color::BlendMode::Normal + && row.paint() == paint + && match row { + AppearanceItem::Fill(_) => true, + AppearanceItem::Stroke(st) => paint.is_none() || st.width == width, + } + } + (Some(_), Some(_)) => false, + } } -/// [`shown_stroke`], to change. -fn shown_stroke_mut(e: &mut Node) -> Option<&mut super::StrokeLayer> { - e.appearance.items.iter_mut().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Paint the fills (`fill`) or strokes of appearance `a` with `paint` (a stroke at `width`), as the +/// Live Paint Bucket does: every row of that kind is replaced by one row, shown, fully opaque and +/// blending normally, in the place of the topmost row of that kind. The new row keeps the other +/// settings (a stroke's dashes, caps and arrowheads, a row's effects) of the topmost row of that +/// kind that is shown, or of the topmost one when every row is hidden. Rows of the other kind stay +/// as they are. With no row of that kind, a fill is added below the strokes and a stroke above the +/// fills. +fn bucket_paint(a: &mut Appearance, fill: bool, paint: &super::Paint, width: f64) { + let of_kind = |i: &AppearanceItem| i.is_fill() == fill; + let Some(top) = a.items.iter().rposition(of_kind) else { + if fill { + a.set_fill(paint.clone()); + } else { + a.items.push(AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))); + } + return; + }; + let base = a.items.iter().rposition(|i| of_kind(i) && i.visible()).unwrap_or(top); + let Some(mut row) = a.items.get(base).cloned() else { return }; + match &mut row { + AppearanceItem::Fill(f) => { + f.paint = paint.clone(); + f.opacity = 1.0; + f.blend = vectorcraft_color::BlendMode::Normal; + f.visible = true; + } + AppearanceItem::Stroke(st) => { + st.paint = paint.clone(); + st.width = width; + st.opacity = 1.0; + st.blend = vectorcraft_color::BlendMode::Normal; + st.visible = true; + } + } + let mut row = Some(row); + a.items = std::mem::take(&mut a.items) + .into_iter() + .enumerate() + .filter_map(|(i, item)| { + if i == top { + row.take() + } else if of_kind(&item) { + None + } else { + Some(item) + } + }) + .collect(); } /// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index a117bd453e..e9288918e8 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -22,6 +22,22 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +/// The name of the group that holds an Abutting trace's seam fills (see [`tr::TraceResult::seams`]). +const SEAM_FILLS_NAME: &str = "Seam Fills"; + +/// Whether `n` can be a trace's seam fills group: a group named as one, or a group of stroked paths +/// without fills, so that it is still found after it is renamed or its strokes are edited. (Auto +/// Grouping groups hold filled paths.) +fn is_seam_fills(n: &Node) -> bool { + let stroke_only = |m: &Arc| { + matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && m.appearance.fill_paint() == Paint::None + && m.appearance.stroke_paint() != Paint::None + }; + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && (n.name.as_deref() == Some(SEAM_FILLS_NAME) || n.children().is_some_and(|c| !c.is_empty() && c.iter().all(stroke_only))) +} + const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; /// The `view` param ([`TraceView`] id), if given. @@ -393,11 +409,15 @@ fn by_color_param(cmd: &str, p: &Value) -> Result { /// (by the boxes of everything they paint, effects included, within one point); otherwise it /// starts another group of that paint, which keeps the stacking order of those items. Items /// farther apart can still share antialiased pixels at small view or raster-export scales, so -/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +/// their edge pixels may change. The seam fills' group, when it is the lowest item, stays as it is, +/// under every other group. Any other group among `items` (Auto Grouping) is refused. fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { use std::collections::{BTreeMap, HashMap}; use std::ops::Bound::{Excluded, Unbounded}; use vectorcraft_color::tint_percent; + // The seam fills come first, under every path: kept there, they move under nothing. + let mut items = items; + let seams: Vec> = if items.first().is_some_and(|n| is_seam_fills(n)) { items.drain(..1).collect() } else { vec![] }; // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a // gradient, or another paint (its label). The name is apart from it: a swatch may be named as // a color's `#rrggbb` or as "Gradients". @@ -517,14 +537,15 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result> = groups .into_iter() .map(|run| { let mut g = Node::group(d.alloc_id(), run.members); g.name = Some(run.name); Arc::new(g) }) - .collect()) + .collect(); + Ok(seams.into_iter().chain(groups).collect()) } /// What a trace depends on: the document it is made in, the object traced (an image, or an Image @@ -608,11 +629,13 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } -/// What a trace made of an image: the size in pixels it was decoded at, and the paths. +/// What a trace made of an image: the size in pixels it was decoded at, the paths, and their seam +/// fills ([`seams_within`]). pub struct Traced { inputs: TraceInputs, raster: (u32, u32), res: tr::TraceResult, + seams: Vec, } impl Traced { @@ -636,7 +659,18 @@ fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64 tr::TraceError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), })?; - Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res }) + let seams = seams_within(&res, params, MAX_TRACE_ANCHORS); + Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res, seams }) +} + +/// The seam fills of `res`, traced with `params` ([`tr::seam_fills`]), while the paths and the seam +/// fills together stay within `max` anchors: the seam fills have at most as many anchors as the +/// paths, so a trace whose paths have more than half of `max` gets none. +fn seams_within(res: &tr::TraceResult, params: &tr::TraceParams, max: usize) -> Vec { + if res.anchor_count() > max / 2 { + return vec![]; + } + tr::seam_fills(&res.paths, params) } /// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`] @@ -716,7 +750,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, _ => trace_now(&inputs, &trace, &|_, _| true)?, }; @@ -729,6 +763,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result> = seam_fills + .iter() + .filter_map(|tp| { + let width = tp.stroke?; + let mut n = shape_node(d, tp.path.transformed(xf), None); + n.appearance = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: LineCap::Butt, + join: LineJoin::Round, + ..StrokeLayer::new(paint_of(tp), width * seam_unit) + })], + ..Default::default() + } + .into(); + Some(Arc::new(n)) + }) + .collect(); + if !seams.is_empty() { + let mut g = Node::group(d.alloc_id(), seams); + g.name = Some(SEAM_FILLS_NAME.into()); + children.push(Arc::new(g)); + } let mut traced = Vec::with_capacity(res.paths.len()); for tp in res.paths { + let paint = paint_of(&tp); let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); // Shapes: a path the tracer found to be a shape is that live shape. @@ -764,10 +835,6 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Appearance { @@ -1103,9 +1170,28 @@ mod color_group_tests { #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, seams_within, trace_warnings}; use vectorcraft_trace as tr; + #[test] + fn seam_fills_count_against_the_anchor_limit() { + // Two colors side by side, the edge between them anti-aliased: one seam fill along it. + let img = tr::Raster::from_fn(24, 16, |x, _| { + let share = (10.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }); + let params = tr::TraceParams { mode: tr::Mode::Color, colors: 2, method: tr::Method::Abutting, ..tr::TraceParams::default() }; + let res = tr::trace(&img, ¶ms); + let all = tr::seam_fills(&res.paths, ¶ms); + assert!(!all.is_empty(), "{res:?}"); + let anchors = res.anchor_count(); + // Twice the paths' anchors leaves room for any seam fills. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors), all); + // One anchor short of that: none. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors - 1), []); + } + #[test] fn keep_warnings_name_matched_colors_that_produce_no_paths() { let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 446634d21c..4e086569ae 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -32,7 +32,7 @@ use crate::DocState; pub fn specs() -> Vec { let mut v = vec![ - cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), + cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg), cmd!( query "clipboard.importSvg", "Load SVG into Clipboard", @@ -435,14 +435,37 @@ impl Clipboard { } impl Session { - /// The internal clipboard as standalone SVG (`None` when it is empty). + /// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name + /// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]). pub fn clipboard_svg(&self) -> Option { + if self.clipboard_svg_refused() { + return None; + } self.clipboard.svg() } + + /// Would the internal clipboard's SVG name a linked image's file outside the read roots in + /// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative + /// path is followed from the document the objects came from while it is open; otherwise it + /// counts as outside. + pub fn clipboard_svg_refused(&self) -> bool { + self.check_clipboard_svg("clipboard.exportSvg").is_err() + } + + /// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder. + fn check_clipboard_svg(&self, cmd: &str) -> Result<()> { + if crate::file_access::current().is_none() || !self.clipboard.has_art() { + return Ok(()); + } + let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc); + let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of); + super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({})) + } } fn export_svg(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "svg": s.clipboard_svg() })) + s.check_clipboard_svg("clipboard.exportSvg")?; + Ok(json!({ "svg": s.clipboard.svg() })) } fn import_svg(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 8bf6841e17..d863edb96d 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; +use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, Policy, ProofTarget}; use vectorcraft_color::recolor::Neutral; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, file.newFromTemplate, file.revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and bpc and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Assign Profile…", ["Edit"], None, - "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", + "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: no profile, Don't Color Manage This Document, so colors show through the working space; the working space's name: that profile stays assigned when the working space changes). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}", has_doc, assign_profile ), @@ -163,12 +163,26 @@ fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { switch_or(p, key, false, cmd).map(Some) } +/// The policy `p` gives for `key`, one of `allowed`, or `None` when `p` leaves it out. +fn policy_param(p: &Value, key: &str, allowed: &[Policy], cmd: &str) -> Result> { + let Some(v) = text_param(p, key, cmd)? else { return Ok(None) }; + match Policy::parse(v).filter(|x| allowed.contains(x)) { + Some(x) => Ok(Some(x)), + None => { + let ids: Vec<&str> = allowed.iter().map(Policy::id).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", ids.join(", ")))) + } + } +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, "cmyk": st.cmyk, "intent": st.intent.id(), "bpc": st.bpc, + "rgbPolicy": st.rgb_policy.id(), + "cmykPolicy": st.cmyk_policy.id(), "profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::>(), }) } @@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { st.intent = i; } st.bpc = switch_or(p, "bpc", st.bpc, C)?; + if let Some(v) = policy_param(p, "rgbPolicy", &Policy::RGB, C)? { + st.rgb_policy = v; + } + if let Some(v) = policy_param(p, "cmykPolicy", &Policy::CMYK, C)? { + st.cmyk_policy = v; + } if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; // Saved for the next start when the app attached a settings file (#6335). @@ -530,7 +550,12 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, if !convert { return 0; } - let conv = mode_converter(mode, intent, grays); + convert_colors(d, &mode_converter(mode, intent, grays)) +} + +/// Every color of `d` that [`set_color_mode`] converts, converted with `conv` → how many colors of +/// the art changed, as [`set_color_mode`] counts them. +fn convert_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) -> usize { for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -538,6 +563,11 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, for st in &mut g.gradient.stops { st.color = conv(&st.color); } + if let Some(freeform) = &mut g.freeform { + for point in &mut freeform.points { + point.color = conv(&point.color); + } + } } _ => {} } @@ -621,6 +651,102 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, in_art } +/// Apply the color management policies of Edit → Color Settings to `d`, a document being opened: +/// each RGB or CMYK profile it is tagged with that is not the working space of its model is kept +/// (Preserve Embedded Profiles), taken off with the color numbers kept (Off, Preserve Numbers), or +/// taken off after the colors of that model were converted from it into the working space with the +/// working intent (Convert to Working Space). A profile that is not available here is taken off +/// by Off and Preserve Numbers and kept by Convert to Working Space, which has nothing to convert +/// from. → a note for each profile, and whether `d` changed. +pub(crate) fn apply_open_policies(d: &mut vectorcraft_doc::Document) -> (Vec, bool) { + let working = cms::working_settings(); + let (rgb, cmyk) = doc_profiles(d); + let mut notes = Vec::new(); + // What each model's policy does with its profile: `None` keeps it; `Some` takes it off, after + // converting the colors from the profile it holds. + let mut decide = |label: &str, tag: Option, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option> { + let tag = tag.filter(|t| t != working)?; + let available = cms::profile(&tag).is_some_and(|p| p.kind == kind); + match policy { + Policy::PreserveEmbedded => None, + Policy::Off | Policy::PreserveNumbers => { + notes.push(format!( + "the {label} profile “{tag}” the file is tagged with was not used: Color Settings' {label} policy is {}", + policy.label() + )); + Some(None) + } + Policy::Convert if available => { + notes.push(format!( + "the {label} colors were converted from the profile “{tag}” the file is tagged with into the working space “{working}”" + )); + Some(Some(tag)) + } + Policy::Convert => { + notes.push(format!("the {label} profile “{tag}” the file is tagged with is not available, so its colors were not converted")); + None + } + } + }; + let rgb_do = decide("RGB", rgb, &working.rgb, working.rgb_policy, cms::ProfileKind::Rgb); + let cmyk_do = decide("CMYK", cmyk, &working.cmyk, working.cmyk_policy, cms::ProfileKind::Cmyk); + if rgb_do.is_none() && cmyk_do.is_none() { + return (notes, false); + } + let (from_rgb, from_cmyk) = (rgb_do.clone().flatten(), cmyk_do.clone().flatten()); + if from_rgb.is_some() || from_cmyk.is_some() { + let source = cms::for_document(from_rgb.as_deref(), from_cmyk.as_deref()); + let dest = cms::working(); + let intent = working.intent; + let (rgb_on, cmyk_on) = (from_rgb.is_some(), from_cmyk.is_some()); + let conv = |c: &Color| match c { + Color::Rgb { .. } if rgb_on => source.convert_into(&dest, c, intent), + Color::Cmyk { .. } if cmyk_on => source.convert_into(&dest, c, intent), + _ => *c, + }; + convert_colors(d, &conv); + convert_setting_colors(d, &conv); + } + if rgb_do.is_some() { + d.color_profiles.rgb = None; + } + if cmyk_do.is_some() { + d.color_profiles.cmyk = None; + } + (notes, true) +} + +/// The colors of `d` outside its art that [`convert_colors`] leaves, converted with `conv`: each +/// artboard's background, the transparency grid and simulated paper colors of Document Setup, and +/// the fill and stroke of the character and paragraph styles. +fn convert_setting_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) { + for c in d.artboards.iter_mut().filter_map(|a| a.color.as_mut()) { + *c = conv(c); + } + for c in &mut d.setup.grid_colors { + *c = conv(c); + } + // A tint of a global swatch is the converted swatch's color at its tint, which + // [`convert_colors`] already gave it. + let bases = global_bases(d); + let mut f = |c: &Color, link: proof::Link| mode_color(c, link, &bases, conv); + for styles in [&mut d.char_styles, &mut d.para_styles] { + if !styles.iter().any(|s| s.attrs.contains_key("fill") || s.attrs.contains_key("stroke")) { + continue; + } + for def in styles.iter_mut() { + for key in ["fill", "stroke"] { + let Some(value) = def.attrs.get_mut(key) else { continue }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { continue }; + map_paint_colors(&mut paint, &mut f); + if let Ok(v) = serde_json::to_value(&paint) { + *value = v; + } + } + } + } +} + /// [`proof::map_node_colors`] over `n`'s subtree, then [`map_more_colors`]. fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { proof::map_node_colors(n, f); diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 3925612a14..131695e33e 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -1,15 +1,18 @@ //! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), //! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a -//! font of its own. Type names a composite font as its family, and the font menus list them first. -//! The font database holds the active document's composite fonts ([`sync`]); a file exported here -//! carries them to another document. +//! font of its own, and the characters of each custom character set in the set's font. Type names +//! a composite font as its family, and the font menus list them first. The font database holds the +//! active document's composite fonts ([`sync`]); a file exported here carries them to another +//! document. //! //! Behavior reference (public documentation): -//! . +//! , +//! custom character sets: +//! . use serde_json::{Map, Value, json}; -use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; -use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; use vectorcraft_text::FontDb; use super::fileio::{read_file_head, write_file}; @@ -26,6 +29,10 @@ const MAX_FILE_BYTES: usize = 4 << 20; /// The keys a part may give. const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; +/// The keys a custom character set may give: its name and characters, then its part's values +/// (a set is never scaled at the center). +const SET_KEYS: [&str; 8] = ["name", "characters", "font", "style", "size", "baseline", "hScale", "vScale"]; + pub fn specs() -> Vec { vec![ cmd!( @@ -33,7 +40,7 @@ pub fn specs() -> Vec { "Composite Fonts", [], None, - "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}, custom?: [{name, characters, font, style, size, baseline, hScale, vScale}]}]}", has_doc, list ), @@ -42,7 +49,7 @@ pub fn specs() -> Vec { "Composite Fonts…", ["Type"], None, - "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}, custom?: [{name, characters?: string = \"\", font?, style?, size?, baseline?, hScale?, vScale?}]}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. custom lists custom character sets: each sets the characters it lists in its own font, style, size, baseline and scales (values left out are the kanji part's), ahead of their class; a character two sets list is set by the later one. A set's name is trimmed and unique in the font in any case; its characters are each listed once, at most 8192, none a control character or past U+FFFF; at most 64 sets. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", has_doc, set ), @@ -51,7 +58,7 @@ pub fn specs() -> Vec { "Export Composite Fonts", [], None, - "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads (a file over 4 MB is refused); without path the file's text comes back as data → {path, count} | {data, count}", has_doc, export ), @@ -121,8 +128,8 @@ fn list(s: &mut Session, _: &Value) -> Result { /// copy of the Kanji part, and keys or values a part can't take refused. fn parse_font(v: &Value, c: &str) -> Result { let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; - if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { - return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + if let Some(k) = o.keys().find(|k| !["name", "parts", "custom"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name, parts and custom"))); } let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; let name = CompositeFont::clean_name(raw) @@ -140,7 +147,17 @@ fn parse_font(v: &Value, c: &str) -> Result { serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) }; let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; - let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + let custom = match o.get("custom") { + None => Vec::new(), + Some(Value::Array(sets)) => { + if sets.len() > CUSTOM_SETS_MAX { + return Err(bad(c, format!("`{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets"))); + } + sets.iter().map(|s| parse_set(s, &name, &kanji, c)).collect::>>()? + } + Some(_) => return Err(bad(c, format!("`{name}`: custom must be a list of character sets"))), + }; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji), custom }; for class in CharClass::ALL.into_iter().skip(1) { if let Some(p) = part(class)? { *f.parts.get_mut(class) = p; @@ -150,6 +167,38 @@ fn parse_font(v: &Value, c: &str) -> Result { Ok(f) } +/// Custom character set `v` of composite font `font` from command parameters or a file: its name +/// trimmed, the part values it leaves out those of `kanji`, and keys or values it can't take +/// refused. [`CompositeFont::check`] checks its characters and values. +fn parse_set(v: &Value, font: &str, kanji: &CompositePart, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, format!("`{font}`: each character set must be {{name, characters, font…}}")))?; + if let Some(k) = o.keys().find(|k| !SET_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{font}`: unknown key `{k}` in a character set"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, format!("`{font}`: each character set needs a name")))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let characters = match o.get("characters") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(_) => return Err(bad(c, format!("`{font}`: `{name}`: characters must be a string"))), + }; + // Checked here as well as by `check`, so a huge list is refused before it is copied again. + if characters.chars().nth(CUSTOM_CHARS_MAX).is_some() { + return Err(bad(c, format!("`{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters"))); + } + let mut part = serde_json::to_value(kanji).map_err(|e| bad(c, e.to_string()))?; + if let Some(p) = part.as_object_mut() { + for k in &SET_KEYS[2..] { + if let Some(given) = o.get(*k) { + p.insert(k.to_string(), given.clone()); + } + } + } + let part: CompositePart = serde_json::from_value(part).map_err(|e| bad(c, format!("`{font}`: `{name}`: {e}")))?; + Ok(CustomCharSet { name, characters, part }) +} + /// Error unless `fonts` can stand together in a document: names unique in any case, none naming /// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { @@ -167,10 +216,10 @@ fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { } } for f in fonts { - for class in CharClass::ALL { - let font = &f.parts.get(class).font; + let classes = CharClass::ALL.into_iter().map(|class| (class.key(), &f.parts.get(class).font)); + for (part, font) in classes.chain(f.custom.iter().map(|s| (s.name.as_str(), &s.part.font))) { if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { - return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, part))); } } } @@ -260,6 +309,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let mut file = Map::new(); file.insert("compositeFonts".into(), json!(fonts)); let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + if text.len() > MAX_FILE_BYTES { + return Err(bad(C, "the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them")); + } match path { Some(path) => { write_file(path, text.as_bytes())?; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index da310a89b9..751cb49ef0 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -856,3 +856,127 @@ fn composite_fonts_export_and_import() { assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// `font(name)` with two custom character sets: "Marks" (x and A) in Inter at 150%, and "A only" +/// (A) in Source Sans 3, which sets A since it comes later. +fn font_with_sets(name: &str) -> Value { + let mut v = font(name); + v["custom"] = json!([ + {"name": " Marks ", "characters": "xA", "font": "Inter", "size": 150, "hScale": 90}, + {"name": "A only", "characters": "A"}, + ]); + v +} + +/// Custom character sets are set with the font as one undo step, take values they leave out from +/// the Kanji part, set the characters they list ahead of their class (the later set winning), are +/// saved with the document, and measure type again when they change. +#[test] +fn custom_character_sets_set_their_characters() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(r["fonts"][0]["custom"][0]["name"], "Marks"); + assert_eq!(steps(&s), before + 1); + let f = s.doc().unwrap().doc.composite_fonts[0].clone(); + assert_eq!(f.custom.len(), 2); + assert_eq!((f.custom[0].part.font.as_str(), f.custom[0].part.size, f.custom[0].part.h_scale), ("Inter", 150.0, 90.0)); + assert_eq!((f.custom[1].part.font.as_str(), f.custom[1].part.size), ("Source Sans 3", 100.0)); + // The same sets again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "xAB", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Sets"})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &text(&s, id)); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("Inter"), face("Source Sans 3"), face("JetBrains Mono")]); + // Taking x out of Marks sets it in its class's font again, measured in the same step. + let wide = text(&s, id).cached_bounds.unwrap().width(); + let mut fewer = font_with_sets("CF Sets"); + fewer["custom"][0]["characters"] = json!("A"); + s.execute("type.compositeFonts.set", &json!({"fonts": [fewer]})).unwrap(); + assert!(text(&s, id).cached_bounds.unwrap().width() < wide - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); +} + +/// Bad custom character sets are refused with an error and no undo step. +#[test] +fn bad_custom_character_sets_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font_with_sets("CF Sets Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(8193).collect(); + let sets: Vec = (0..65).map(|i| json!({"name": format!("S{i}")})).collect(); + for bad in [ + with(&|v| v["custom"] = json!("Marks")), + with(&|v| v["custom"] = Value::Null), + with(&|v| v["custom"][0]["characters"] = Value::Null), + with(&|v| v["custom"][0] = json!("Marks")), + with(&|v| v["custom"][0]["name"] = json!(" ")), + with(&|v| { + v["custom"][0].as_object_mut().unwrap().remove("name"); + }), + with(&|v| v["custom"][1]["name"] = json!("marks")), + with(&|v| v["custom"][0]["characters"] = json!(["※"])), + with(&|v| v["custom"][0]["characters"] = json!("※※")), + with(&|v| v["custom"][0]["characters"] = json!("A\n")), + with(&|v| v["custom"][0]["characters"] = json!("😀")), + with(&|v| v["custom"][0]["characters"] = json!(many.clone())), + with(&|v| v["custom"] = json!(sets.clone())), + with(&|v| v["custom"][0]["size"] = json!(5)), + with(&|v| v["custom"][0]["size"] = json!("50")), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(true)), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(false)), + with(&|v| v["custom"][0]["font"] = json!("")), + with(&|v| v["custom"][0]["font"] = json!("CF Sets Base")), + with(&|v| v["custom"][0]["width"] = json!(3)), + ] { + let e = s.execute("type.compositeFonts.set", &bad); + assert!(e.is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Sets Base"]); + assert_eq!(steps(&s), before); + } + // A set without characters is kept: it sets nothing. + let ok = with(&|v| v["custom"][0]["characters"] = json!("")); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(s.doc().unwrap().doc.composite_fonts[0].custom[0].characters, ""); +} + +/// Export writes the custom character sets and Import reads them back; a file whose set is bad is +/// refused. +#[test] +fn custom_character_sets_export_and_import() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets IO")]})).unwrap(); + let data = s.execute("type.compositeFonts.export", &json!({})).unwrap(); + let mut t = session(); + t.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(*t.doc().unwrap().doc.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + let text = data["data"].as_str().unwrap().replace("\"A only\"", "\"marks\""); + let mut u = session(); + assert!(u.execute("type.compositeFonts.import", &json!({"data": text})).is_err()); + assert!(names(&u).is_empty()); + // Fonts whose file would be larger than Import reads are refused. + let full: String = (0x4E00u32..).filter_map(char::from_u32).take(8192).collect(); + let sets: Vec = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect(); + let big: Vec = (0..3) + .map(|i| { + let mut f = font(&format!("CF Sets Big {i}")); + f["custom"] = json!(sets); + f + }) + .collect(); + let e = s.execute("type.compositeFonts.export", &json!({"fonts": big})).unwrap_err(); + assert!(e.to_string().contains("4 MB"), "{e}"); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index ebef729fd3..08944940a2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?, ringSeed? (whole number below 2^53: places the rings along the path anew, keeping their count and sizes)} → {id}", has_doc, flare ), @@ -429,6 +429,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { "direction", "pathLength", "seed", + "ringSeed", ]; check_numbers(p, &numbers, "shape.flare")?; let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0); @@ -450,14 +451,28 @@ fn flare(s: &mut Session, p: &Value) -> Result { c + vectorcraft_geom::Vec2::new(a.cos(), -a.sin()) * f64_or(p, "pathLength", 300.0) } }; - // Deterministic pseudo-random sizes (same parameters → same flare). - let mut seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0x2545_f491_4f6c_dd1d) | 1; - let mut rnd = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 11) as f64 / (1u64 << 53) as f64 + // Deterministic pseudo-random sizes (same parameters → same flare). `ringSeed` places the rings + // along the path from a sequence of its own; their count and sizes and the other parts stay. + let seed_of = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("shape.flare", format!("`{key}` must be a whole number of at least 0, not {v}"))), }; + // The generator's state is odd (never 0). + let random = |mut state: u64| { + move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 11) as f64 / (1u64 << 53) as f64 + } + }; + let mut rnd = random(seed_of("seed")?.unwrap_or(0x2545_f491_4f6c_dd1d) | 1); + // Each `ringSeed` below 2^53 (what a JSON number holds exactly) gets a state of its own. + let ring_seed = match seed_of("ringSeed")? { + Some(r) if r >= 1 << 53 => return Err(bad("shape.flare", format!("`ringSeed` must be below 2^53, not {r}"))), + r => r, + }; + let mut ring_at = ring_seed.map(|r| random((r << 1) | 1)); let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops)); @@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { // Rings along the path to the end point (Largest = % of the average ring size). for _ in 0..rings { let t = rnd(); + let t = ring_at.as_mut().map_or(t, |next| next()); let at = c + (end - c) * t; let rr = (r * largest * (0.25 + rnd() * 0.75)).max(0.5); let solid = rnd() < 0.5; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index d469c7a613..9055e83844 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -33,3 +33,4 @@ mod smooth; pub use commands::specs; pub(super) use common::{ids_json, replace_with_pieces, usize_opt}; pub(super) use cut::{components, erase_outline}; +pub(super) use freehand::freehand_brushed; diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index ffa11e362c..49e277e5a0 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 645c184b14..64b8bdee16 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,11 +1,14 @@ use super::common::{ - dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param, - sibling_with, size_pressure, target_paths, usize_opt, + PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, + samples_param, sibling_with, size_pressure, target_paths, usize_opt, }; use super::{ - Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session, + SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; +use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation}; +use vectorcraft_doc::StrokeLayer; +use vectorcraft_tools::draw2::MIN_BRUSH_SIZE; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { @@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { } let angle = number_or(p, "angle", 0.0, C)?; let roundness = number_or(p, "roundness", 100.0, C)?; - let pressure = size_pressure(p, &samples, C)?; + let vary = eraser_variations(p, C)?; + if varies_beyond_size_pressure(&vary) { + return erase_varied(s, &samples, [angle, roundness, size], vary); + } + let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None }; let pts: Vec = samples.into_iter().map(|s| s.pt).collect(); let r = size / 2.0; // The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it. @@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { apply_plan(s, "Eraser", plan) } +/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and +/// `sizeMode`. +const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + +/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with +/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation` +/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how +/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null +/// is 0; any other mode, or a variation that is negative or past its most, is refused. +fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> { + const KEYS: [(&str, &str, f64); 3] = + [("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)]; + let mut out = [(Variation::Fixed, 0.0); 3]; + for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) { + let mode = match p.get(mode_key).filter(|v| !v.is_null()) { + None => Variation::Fixed, + Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?, + Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))), + }; + let variation = number_or(p, var_key, 0.0, cmd)?; + if !(variation.is_finite() && variation >= 0.0) { + return Err(bad(cmd, format!("{var_key} must be 0 or more"))); + } + if variation > most { + return Err(bad(cmd, format!("{var_key} must be at most {most}"))); + } + *slot = (mode, variation); + } + Ok(out) +} + +/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on +/// Pressure? A mode whose variation is 0 does not vary. +fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool { + vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure)) +} + +/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the +/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and +/// variations, with what the pen recorded along the drag. Random draws one value for the drag +/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes +/// the value itself. `tip`: the angle, roundness and size. +fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result { + let [angle, roundness, size] = tip; + let mut pts: Vec = samples.iter().map(|s| s.pt).collect(); + // A press without a drag is a dab: a sweep needs some length. + if let [only] = pts[..] { + pts.push(only + Vec2::new(0.01, 0.0)); + } + // A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept + // at a fraction of its size on a stroke as many times wider. + let [(am, av), (rm, rv), (sm, sv)] = vary; + let sv = if sm == Variation::Fixed { 0.0 } else { sv }; + let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0); + let nib = Calligraphic { + angle, + roundness: roundness.clamp(1.0, 100.0), + size: size / scale, + variation: [av, rv, sv / scale], + modes: Some([am, rm, sm]), + }; + let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) }; + let mut stroke = StrokeLayer::new(Paint::None, scale); + PenRecord::of_samples(samples).write(&mut stroke); + let spine = SubPath::polyline(&pts, false); + let st = s.doc()?; + let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke); + let areas: Vec = pieces.iter().filter_map(|n| n.path_data().cloned()).collect(); + if areas.is_empty() { + return Ok(json!({ "ids": [] })); + } + let region = + po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::>()).map_err(|e| EngineError::Other(e.to_string()))?; + let bp = region.to_bezpath(); + // The narrowest the tip gets across: an open path is sampled at least every half of it. + let low = |i: usize| match vary[i] { + (Variation::Fixed, _) => tip[i], + (_, v) => tip[i] - v, + }; + let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0; + let reach = (size + sv) / 2.0; + let mut plan: Vec<(NodeId, Vec)> = vec![]; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? { + if pd.is_closed() { + if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { + continue; + } + plan.push((id, components(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Difference)))); + } else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) { + plan.push((id, pieces)); + } + } + apply_plan(s, "Eraser", plan) +} + fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) -> Result { if plan.is_empty() { return Ok(json!({ "ids": [] })); diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 401bdd2977..45c25eb72c 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -157,6 +157,34 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) - } pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + freehand_brushed(s, p, None) +} + +/// The object a brush on path `id` paints: the compound path a member path is in, else the path. +fn brush_target(d: &Document, id: NodeId) -> NodeId { + d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// Whether `ap` has a stroke a brush can paint: a stroke whose paint is not None. +fn can_brush(ap: &Appearance) -> bool { + ap.stroke().is_some_and(|st| !st.paint.is_none()) +} + +fn no_stroke(name: &str) -> EngineError { + bad("brush.freehand", format!("cannot apply brush `{name}`: path has no stroke")) +} + +/// Sets brush `name` on the stroke of object `id` of `d`. +fn set_brush(d: &mut Document, id: NodeId, name: &str) { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.to_string()); + } +} + +/// `path.freehand`, with the stroke painted with brush `brush_name` (one `brush.freehand` has +/// checked) in the same undo step as the path. A path without a stroke is an error before +/// anything changes. +pub(crate) fn freehand_brushed(s: &mut Session, p: &Value, brush_name: Option<&str>) -> Result { const C: &str = "path.freehand"; let samples = samples_param(p, "points", C)?; if samples.iter().any(|q| !within_input_bound(q.pt)) { @@ -173,6 +201,8 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); let closed = switch_or(p, "closed", false, C)?; let brush = str_param(p, "style") == Some("brush"); + // The undo step's name: a stroke painted with a brush is a Paintbrush stroke, whatever its style. + let label = if brush || brush_name.is_some() { "Paintbrush" } else { "Pencil" }; let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { @@ -180,6 +210,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let target = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(target).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } let mut sp = pd.subpaths[si].clone(); let old_len = subpath_len(&sp); if at_start { @@ -191,6 +227,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; *p0 = end; if pts.len() < 2 { + // No stroke to add: the brush alone changes, when it is not the path's already. + if let Some(name) = brush_name + && s.doc()?.doc.node(target).and_then(|n| n.appearance.stroke()).is_some_and(|st| st.brush.as_deref() != Some(name)) + { + s.edit("Paintbrush", |d, _| { + set_brush(d, target, name); + Ok(()) + })?; + } return Ok(json!({ "id": id.0 })); } // A stroke that ends on an open end of another path connects the two: (that path, its last @@ -290,7 +335,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let drawn = old.extended(old_len, &pen, drawn_len - old_len); if connect.is_some() { drawn.extended(drawn_len, &other, other_len) } else { drawn } }); - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + s.edit(label, |d, sel| { let reverse = at_start && !sp.closed; if reverse { sp.reverse(); @@ -326,6 +371,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { { if reverse { joined.reversed() } else { joined }.write(st); } + if let Some(name) = brush_name { + set_brush(d, target, name); + } if keep_selected { sel.set([id]); } else { @@ -346,11 +394,19 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // were, measured along the old path; fit them to the new length if strokes drawn with a // pen get redrawn. if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + // A member path of a compound path is redrawn in it: the compound path is what is + // selected, and what a brush paints. + let whole = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(whole).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } + s.edit(label, |d, sel| { *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; - // A member path of a compound path is redrawn in it: the compound path is what is - // selected. - let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + if let Some(name) = brush_name { + set_brush(d, whole, name); + } if keep_selected { sel.set([whole]); } else { @@ -373,7 +429,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { if let Some(st) = look.appearance.stroke_mut() { pen.write(st); } - let out = insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)?; + if let Some(name) = brush_name { + if !can_brush(&look.appearance) { + return Err(no_stroke(name)); + } + if let Some(st) = look.appearance.stroke_mut() { + st.brush = Some(name.to_string()); + } + } + let out = insert_look(s, label, path_kind(PathData::single(sp)), look, None, keep_selected)?; if !keep_selected { // A new stroke with Keep selected off leaves nothing selected: Illustrator starts the stroke // by dropping the earlier selection, it does not keep it (#5752). diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6aba8d4ca6..42113c9578 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,10 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of, + with_reference, +}; use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -49,7 +52,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), @@ -67,7 +70,7 @@ fn effect_specs() -> Vec { "Remove Effect", [], None, - "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, remove ), @@ -76,7 +79,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, set_params ), @@ -85,7 +88,7 @@ fn effect_specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -103,7 +106,7 @@ fn effect_specs() -> Vec { "Duplicate Effect", [], None, - "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of each effect right after it, in one undo step → {ids}", + "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} insert a copy of each effect right after it, in one undo step → {ids}", has_doc, duplicate ), @@ -112,7 +115,7 @@ fn effect_specs() -> Vec { "Move Effect", [], None, - "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", has_doc, move_effect ), @@ -296,14 +299,20 @@ fn ids_json(ids: &[NodeId]) -> Value { /// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see /// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target /// edits its equal item, or its equal object-level effect, each target matched on its own whatever -/// the other targets have; in a target without them, `item` and `index` stand for themselves. +/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object +/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack, +/// and a target without them is left out. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, cmd)?; + let of = reference_of(s, p, item, cmd)?; let d = &s.doc()?.doc; - let per = item.per_target(s, &roots); - let first = roots.first().and_then(|f| d.node(*f)); + let per = item.per_target_of(s, of, &roots); + let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f)); // Whether `item` names an item in the stack it is a row of (else the edit is object-level there). - let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)); + let anchor = match of { + Some(of) => d.node(of), + None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)), + }; let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten(); let aligned = aligned(p, cmd)?; let mut out = Vec::with_capacity(roots.len()); @@ -312,7 +321,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us let at = item.effects_item(&n.appearance, cmd)?; // An object-level effect this target shares with the first is where it is in this target. let index = match (first.filter(|_| aligned), at, anchor_level) { - (Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index), + (Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) { + Some(i) => i, + None if of.is_some() && index != APPENDED => continue, + None => index, + }, _ => index, }; out.push((*id, at, index)); @@ -320,6 +333,10 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us Ok(out) } +/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets +/// have. +const APPENDED: usize = usize::MAX; + #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, @@ -362,6 +379,20 @@ fn edit_effects_at( indices: &[usize], mut f: impl FnMut(&mut Vec, &[usize]) -> bool, ) -> Result> { + // With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect, + // and the edit is refused as a whole, as it is in a list that lacks one of them. + if let Some(of) = reference_of(s, p, item, cmd)? + && let Some(n) = s.doc()?.doc.node(of) + { + let ap = &n.appearance; + let len = match item.effects_item(ap, cmd)? { + None => ap.effects.len(), + Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()), + }; + if indices.iter().any(|i| *i >= len) { + return Err(EngineError::Other(none.into())); + } + } // Each target's list and the positions in it, in the order the targets first come up. let mut targets: Vec<(NodeId, Option, Vec)> = vec![]; for &index in indices { @@ -453,13 +484,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + // The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is + // left out of the targets, is the row equal to it there, and a target without one is left out. + let targets = edit_targets(s, p, C)?; + let of = reference_of(s, p, item, C)?; + let per: Vec<(NodeId, Option)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect(); if effects::is_three_d(id) { - let targets = edit_targets(s, p)?; - for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { + for &(target, at) in &per { + let Some(at) = at else { continue }; let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's (or the object's) others. - let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = at.effects_item(&n.appearance, C).ok().flatten(); let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len()))); let on = on.unwrap_or((None, n.appearance.effects.len())); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; @@ -474,14 +510,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } } - let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| { fx.push(effect.clone()); true })?; // The item the effect landed on in the object whose row the item is (the first selected object // for the active item, which applies where it fits), and the effect's index in that object. - let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); - let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + let k = item.anchor(s, &ids); + let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id)); + let at = match of { + Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item), + None => item, + }; + let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten()); let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1)); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } @@ -495,7 +536,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -608,9 +649,9 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. - if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { + if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); } // Where the effect landed in each object it moved in, in the order of `ids`. @@ -898,8 +939,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; let d = &s.doc().map_err(|e| e.to_string())?.doc; + // The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design. + let ids = super::appearance::drop_design_art(d, ids); // A dimension selected with the art it measures is left as it is. - let kept = super::dimension::with_their_art(d, &ids); + let mut kept = super::dimension::with_their_art(d, &ids); + kept.extend(super::appearance::design_art_in(d, &ids)); if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { @@ -1225,12 +1269,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col } fn expand_appearance(s: &mut Session, p: &Value) -> Result { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p, "effect.expandAppearance")?; // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - // A dimension selected with the art it measures is left as it is. - let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); + // A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a + // series. + let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots); + let designs = super::appearance::design_art_in(&d, &roots); + if !designs.is_empty() { + let view = d.to_mut(); + for id in designs { + // Art already gone (inside other art removed) needs nothing more. + let _ = view.remove(id); + } + } + let d = &*d; let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -1240,6 +1294,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); + // The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose + // own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its + // art into that art, which draws it, so that art has no place to go back to. + let designs = super::appearance::design_art_in(d, &roots); // A dimension selected with the art it measures keeps its own lines and label: it is out // of the document while the art expands. let lifted = super::dimension::lift(d, &roots); @@ -1251,6 +1309,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { for id in &roots { expand_node(d, *id, &brushes, &mut out); } + for id in &designs { + if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) { + *slot = was.clone(); + } + } + if !designs.is_empty() { + // A mark that expands into a group stays listed: only what lies in the restored art is left out. + out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a)))); + } super::dimension::put_back(d, lifted); // A dimension whose source was replaced stays where it was, no longer linked. super::dimension::restore_unchanged(&before, d, ended); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 9f4b89d2f4..e80c1b2d36 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in edit_targets(s, p)? { + for id in edit_targets(s, p, C)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6d972beda8..28cd71804a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; +pub(crate) use pdfimport::opened_page_document; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; @@ -124,9 +125,10 @@ mod commands; mod export_names; mod formats; +pub(crate) use access::check_images_in; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; +pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_readonly; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 203a2262b0..cb5b4e85cc 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re Ok(()) } +/// The error for an SVG that would name a linked image's file outside the read roots. +pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots"; + +/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it +/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and +/// those too unless read roots are in force and the file would name a linked image's file outside +/// them (a relative path taken from `st`'s folder; a URL is not checked). An `` names the +/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for +/// the image, for the images of the art each file draws (with chosen artboards, the art over each +/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask +/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with +/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them +/// and each data set's image. The error names no file or folder. +pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> { + check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p) +} + +/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the +/// objects on the clipboard, looked for from the document they came from). +pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> { + if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() { + return Ok(()); + } + // Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden + // layers kept: the encoder refuses them anyway. + let (link, preserve, hidden) = match super::svg::options_map(p) { + Ok(m) => { + let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false)); + (m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers")) + } + Err(_) => (true, true, true), + }; + // The opacity-mask and symbol editing layers are never written. + let d = &*d.without_edit_modes(); + let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden); + if preserve && crate::cmd::links::names_hidden_file(d, base) + || names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f))) + { + return Err(bad(cmd, IMAGE_OUTSIDE)); + } + Ok(()) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a46f46492a..505c87cc8b 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, through the profiles the document keeps after the color management policies of edit.colorSettings, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; the color management policies of edit.colorSettings keep, take off or convert from the profiles the file is tagged with, their notes go to warnings, and a document they changed opens with unsaved changes; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked)", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked)", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked). Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them (an image among the selected objects), as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,