diff --git a/ROADMAP.md b/ROADMAP.md index 1de46b4b4d..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-14 · **Change:** minor (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) +> **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 @@ -225,6 +225,7 @@ 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. | 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/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..e774a24972 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -369,7 +369,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/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/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..62739d3688 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,29 +182,29 @@ 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, 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 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 a9c9482d1d..47b1c83206 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)?; } @@ -728,14 +742,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 @@ -752,7 +759,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 @@ -824,13 +855,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)); @@ -850,6 +886,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() @@ -999,6 +1042,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/solids.rs b/crates/effects/src/solids.rs index c0d44947d7..953768d298 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/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/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 313531bb99..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()); @@ -897,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/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 60aace2351..2ca51b3e00 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -393,8 +393,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY))); } let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); + // A `.ai` keeps the artboard its header gives; an EPS comes in on its page. + let import = if matches!(format.id, "ai" | "ait") { vectorcraft_eps::import_ai } else { vectorcraft_eps::import_names }; restore_or_import(editing, || { - let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) + let r = import(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 8b588975c4..78cca92873 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -966,7 +966,7 @@ fn an_ai_file_opens_its_variable_library() { {"name": "v_file", "kind": "image", "bindings": 0}, {"name": "v_graph", "kind": "graph", "bindings": 0}, {"name": "v_unbound", "kind": "unknown", "bindings": 0}, - ], "locked": true}) + ], "locked": true, "sort": {"by": "none", "descending": false}}) ); let r = s.execute("dataset.list", &json!({})).unwrap(); assert_eq!(r["active"], "ds_two", "{r}"); diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 59caf85d6e..6245a001bd 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -277,3 +277,17 @@ fn ps_and_epsf_files_open_and_place() { let e = writable_format(None, Some("out.ps")).unwrap_err(); assert_eq!(e, "PostScript files can be opened but not written (see document.formats)"); } + +/// A program in the legacy `.ai` format with a template box and no art size (version 3) opens as a +/// `.ai` or `.ait` on a US Letter artboard around the box's center, and as an EPS on its bounding +/// box. +#[test] +fn a_legacy_ai_program_opens_on_its_artboard_and_an_eps_on_its_page() { + let program = b"%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 30 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + for (name, size) in [("old.ai", (612.0, 792.0)), ("old.ait", (612.0, 792.0)), ("old.eps", (20.0, 20.0))] { + let r = load(name, program).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(size.0, size.1), "{name}: {:?}", r.warnings); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index 4fe5f1e49b..6d66b8da4e 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -386,10 +386,32 @@ fn replacing_a_clipped_psd_keeps_its_scale() { assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); let (anchors, _, _) = clip_group(&s.doc().unwrap().doc, NodeId(r["ids"][0].as_u64().unwrap())); assert_eq!(anchors, [[200.0, 150.0], [300.0, 250.0], [100.0, 250.0]]); - // Other art replacing the clip group takes its centre only, as before. + // A plain image replacing the clip group keeps its scale too. let plain = vectorcraft_format::base64_encode(&red_psd(&[])); let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); - assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)), "{r}"); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); +} + +/// A plain image replacing a clipped PSD file takes the clipped image's scale and rotation. +#[test] +fn a_plain_image_replacing_a_clipped_psd_keeps_its_rotation() { + let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources())); + let plain = vectorcraft_format::base64_encode(&red_psd(&[])); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("object.scale", &json!({"sx": 200, "sy": 150})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let xf = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Image(im)) => im.xf.as_coeffs(), + other => panic!("not an image: {other:?}"), + }; + let (_, _, image) = clip_group(&s.doc().unwrap().doc, group); + let before = xf(&s, image); + let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); + let after = xf(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-9), "{after:?} != {before:?}"); } #[test] diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 071ca484ab..7be0a122df 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -40,7 +40,9 @@ pub use commands::specs; pub(crate) use data::{apply_csv, parse_csv}; #[cfg(test)] use data::{fmt_value, to_csv}; +pub use edit::{GraphTypeSubject, graph_type_subject}; pub(crate) use edit::{edit_graph_art, graphs_holding, keep_placed}; +pub use generate::calculated_axes; #[cfg(test)] use generate::generate; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 175bc9f181..378a8e3745 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -2,7 +2,7 @@ use vectorcraft_doc::graph::graph_art_without_type as art_bounds; use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; -use super::generate::{children_bounds, generate}; +use super::generate::{calculated_axes, children_bounds, generate}; use super::series::{picked_series, reselect_series}; use super::{ Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json, @@ -186,6 +186,28 @@ pub(super) fn spec_of(s: &Session, id: NodeId) -> Result { s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } +/// What `graph.setType` without `id` or `seriesIndexes` acts on, for the Graph Type dialog. +pub struct GraphTypeSubject { + /// The selected graph, or the graph that holds the selection. + pub id: NodeId, + /// The series picked in it with Group Selection, in order; empty when the type goes to the whole graph. + pub series: Vec, + /// The least value, the greatest and the number of divisions each value axis is drawn with while its tick values + /// are calculated: the left (or only) axis, then the right axis' own scale (the left one's without Separate Scales). + pub calculated: [(f64, f64, usize); 2], +} + +/// The graph and series that `graph.setType {}` reads its options from, as [`GraphTypeSubject`]. An error without a +/// selected graph, as the command gives. +pub fn graph_type_subject(s: &Session) -> Result { + const C: &str = "graph.setType"; + let p = json!({}); + let id = target(s, &p, C)?; + let spec = spec_of(s, id)?; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + Ok(GraphTypeSubject { id, series: picked_series(s, &p, id, count, C)?, calculated: calculated_axes(&spec) }) +} + fn query_type_options(spec: &GraphSpec, count: usize, picked: &[usize]) -> Value { // With series picked, the type and value axis they share, so the dialog's OK keeps them. let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index ad7066dbcc..f589ed9ae5 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -38,6 +38,101 @@ fn nth(list: &[Option], s: usize) -> Option<&T> { list.get(s).and_then(Option::as_ref) } +/// The data a value range is read from: each series' type, the cells (a blank is `None`), which series use the right +/// axis' own scale, and the number of categories. +struct Values<'a> { + kinds: &'a [GraphKind], + cells: &'a [Vec>], + on_right: &'a [bool], + ncat: usize, +} + +impl Values<'_> { + fn cell(&self, c: usize, s: usize) -> Option { + self.cells.get(c).and_then(|r| r.get(s)).copied().flatten() + } + + fn axis(&self, s: usize) -> usize { + usize::from(self.on_right.get(s).copied().unwrap_or(false)) + } + + /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. + fn range(&self, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { + let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); + let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); + for c in 0..self.ncat { + if !scatter { + for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { + self.extend(c, a, (lo, hi)); + } + } else { + for s in (0..self.kinds.len()).step_by(2) { + if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { + hi[0] = hi[0].max(y); + lo[0] = lo[0].min(y); + xlo = xlo.min(x); + xhi = xhi.max(x); + } + } + } + } + (lo, hi, xlo, xhi) + } + + /// Widen the range of axis `a` by category `c`'s values. + fn extend(&self, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { + let val = |s: usize| self.cell(c, s).unwrap_or(0.0); + let on = |s: &usize, kinds: &[GraphKind]| self.axis(*s) == a && self.kinds.get(*s).is_some_and(|k| kinds.contains(k)); + // Stacked columns (or bars) stack together, and so do areas; other series count one value each. + for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { + let members = || (0..self.kinds.len()).filter(|s| on(s, &stack)); + *hi = hi.max(members().map(|s| val(s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| val(s).min(0.0)).sum()); + } + let single = |s: &usize| on(s, &[GraphKind::Column, GraphKind::Bar, GraphKind::Line]); + for v in (0..self.kinds.len()).filter(single).filter_map(|s| self.cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } +} + +/// The tick values `g`'s value axes are drawn with while they are calculated from the data (Graph Type › Tick Values +/// without Override Calculated Values): the least value, the greatest and the number of divisions of the left (or +/// only) axis, then of the right axis' own scale, which is the left one's unless Separate Scales gives the series on +/// the right axis a scale of their own. A pie graph has no value axis and gives the axis of a graph without data. A +/// single bound the graph has (only a minimum or only a maximum) is calculated with, as the axis is drawn. +pub fn calculated_axes(g: &GraphSpec) -> [(f64, f64, usize); 2] { + // Adding 0 makes a negative zero, which a nice axis around 0 can start at, a plain 0. + let divisions = |(lo, hi, step): (f64, f64, f64)| (lo + 0.0, hi + 0.0, ((hi - lo) / step).round().clamp(1.0, 100.0) as usize); + // The bounds the axis is calculated with: all but a pair that overrides the calculation. + let partial = |min: Option, max: Option| match (min, max) { + (Some(a), Some(b)) if b > a && (b - a).is_finite() => (None, None), + bounds => bounds, + }; + let (min, max) = partial(g.axis_min, g.axis_max); + if g.kind == GraphKind::Radar { + let top = g.rows.iter().flatten().copied().fold(0.0, f64::max); + return [divisions(value_axis(g.ticks, min, max, 0.0, top)); 2]; + } + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + let (scatter, horizontal) = (g.kind == GraphKind::Scatter, matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar)); + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; + let on_right: Vec = (0..nser).map(|s| separate && g.right_series.contains(&s)).collect(); + let cells = if g.kind == GraphKind::Pie { vec![] } else { g.cells() }; + let ncat = g.rows.len().clamp(1, vectorcraft_doc::MAX_GRAPH_CATEGORIES); + let (lo, hi, ..) = Values { kinds: &kinds, cells: &cells, on_right: &on_right, ncat }.range(scatter); + let left = divisions(value_axis(g.ticks, min, max, lo[0], hi[0])); + let used = |right: bool| on_right.contains(&right); + if !used(true) { + return [left; 2]; + } + let (right_min, right_max) = partial(g.right_axis_min, g.right_axis_max); + let right = divisions(value_axis(g.right_ticks, right_min, right_max, lo[1], hi[1])); + if used(false) { [left, right] } else { [right; 2] } +} + /// One graph being generated: its data, the nodes drawn so far and what the legend and shadows still need. struct Plot<'a> { g: &'a GraphSpec, @@ -526,41 +621,7 @@ impl<'a> Plot<'a> { /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. fn value_range(&self, geo: &Geo, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { - let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); - let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); - for c in 0..self.ncat { - if !scatter { - for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { - self.extend_range(geo, c, a, (lo, hi)); - } - } else { - for s in (0..self.nser).step_by(2) { - if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { - hi[0] = hi[0].max(y); - lo[0] = lo[0].min(y); - xlo = xlo.min(x); - xhi = xhi.max(x); - } - } - } - } - (lo, hi, xlo, xhi) - } - - /// Widen the range of axis `a` by category `c`'s values. - fn extend_range(&self, geo: &Geo, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { - let nser = self.nser; - // Stacked columns (or bars) stack together, and so do areas; other series count one value each. - for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { - let members = || (0..nser).filter(|s| geo.axis(*s) == a && stack.contains(&self.kind(*s))); - *hi = hi.max(members().map(|s| self.val(c, s).max(0.0)).sum()); - *lo = lo.min(members().map(|s| self.val(c, s).min(0.0)).sum()); - } - let single = |s: &usize| geo.axis(*s) == a && matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); - for v in (0..nser).filter(single).filter_map(|s| self.cell(c, s)) { - *hi = hi.max(v); - *lo = lo.min(v); - } + Values { kinds: &self.kinds, cells: &self.cells, on_right: &geo.on_right, ncat: self.ncat }.range(scatter) } /// The value axes with ticks and labels, the legend's start after them, and the axis lines. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 6677edac31..ca1f1d2c45 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4670,6 +4670,58 @@ fn group_selected_series_go_on_the_chosen_axis() { assert!(spec(&s, id).right_series.is_empty()); } +/// The Graph Type dialog's subject: the selected graph, the series picked in it, and the tick values each value axis +/// is drawn with while they are calculated, which are the ones the graph's value labels show. +#[test] +fn graph_type_subject_gives_the_picked_series_and_the_calculated_tick_values() { + let mut s = Session::new(); + assert!(super::graph_type_subject(&s).is_err(), "no document"); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + assert!(super::graph_type_subject(&s).is_err(), "no graph selected"); + // Q1 3, 2; Q2 5, 4; Q3 -1, 6. + let id = graph(&mut s, "column"); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![])); + assert_eq!(subject.calculated, [(-2.0, 6.0, 4); 2]); + let shown = |s: &Session| value_labels(s, id).into_iter().map(|(t, _)| t).collect::>(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + // Overriding with them draws the same axis, and the calculated values stay the data's. + s.execute("graph.setType", &json!({"axisMin": -2, "axisMax": 6, "ticks": 4})).unwrap(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 100, "ticks": 0})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 6.0, 4)); + // A single bound is calculated with, as the axis is drawn. + s.execute("graph.setType", &json!({"axisMin": -10, "axisMax": null})).unwrap(); + let (lo, hi, _) = super::graph_type_subject(&s).unwrap().calculated[0]; + let labels = shown(&s); + assert!(lo <= -10.0, "{lo}"); + assert_eq!((labels.first().and_then(|t| t.parse::().ok()), labels.last().and_then(|t| t.parse::().ok())), (Some(lo), Some(hi))); + s.execute("graph.setType", &json!({"axisMin": null, "axisMax": null})).unwrap(); + // Stacked columns stack their positive and their negative values: Q2 reaches 9, Q3 from -1 to 6. + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 10.0, 6)); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6", "8", "10"]); + // A radar graph's axis starts at 0, and a pie graph has none. + s.execute("graph.setType", &json!({"type": "radar"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 6.0, 3); 2]); + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1.0, 5); 2]); + // Series picked with Group Selection. + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + let picked = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [picked.0]})).unwrap(); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![1])); + // Separate Scales: the right axis has the tick values of its own series. + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1000.0, 5); 2]); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 10.0, 5), (0.0, 1000.0, 5)]); + assert_eq!(spec(&s, id).right_series, [1]); +} + #[test] fn bar_graphs_keep_their_value_axis_along_the_bottom() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/info.rs b/crates/engine/src/cmd/info.rs new file mode 100644 index 0000000000..2f99e9aab2 --- /dev/null +++ b/crates/engine/src/cmd/info.rs @@ -0,0 +1,163 @@ +//! Window › Info (`info.readout`): the selection's size and the active tool's readout, as the Info +//! panel shows them. + +use serde_json::{Value, json}; +use vectorcraft_render::effects; + +use super::{CommandSpec, Result, Session, always, cmd}; + +pub fn specs() -> Vec { + vec![cmd!( + query "info.readout", + "Info", + [], + None, + "{} → {tool: the active tool's id, width?, height?: the sides of the selection's bounding box in pt (the box around the anchors selected with Direct Selection, when there are some), measured as prefs usePreviewBounds says; left out with nothing selected, readout?: what the Info panel shows for the tool in use: {kind: \"drag\", dx, dy, distance, angle} while the Selection, Direct Selection or Group Selection tool moves the selection or the Gradient tool draws a gradient vector on the art; {kind: \"scale\", width, height} (percent) during a Scale tool drag; {kind: \"rotate\", center: [x, y], angle: deg counter-clockwise, as object.rotate takes it} and {kind: \"reflect\", center, angle: the axis in deg counter-clockwise from horizontal, as object.reflect takes it} during a Rotate or Reflect tool drag; {kind: \"shear\", center, axis: 0 for a horizontal shear and 90 for a vertical one, angle: deg, as object.shear takes it} during a Shear tool drag; {kind: \"brush\", brush: name|null} while the Paintbrush is the tool; left out for any other tool and between drags. Lengths are in pt, a drag's angle is in deg counter-clockwise from the x axis, and center is the center of the selection's bounding box as the drag has carried it, in document coordinates}", + always, + readout + )] +} + +impl Session { + /// The sides of the selection's bounding box as the Info panel shows them: of the box around + /// the anchors selected with Direct Selection when there are some, else of the bounding box + /// rotated with rotated objects ([`Session::transform_box`]), measured as Use Preview Bounds + /// says. None with nothing selected. + pub fn info_size(&self) -> Option<(f64, f64)> { + let st = self.active()?; + let anchors = effects::preview::anchor_bounds_with(&st.selection, &st.doc, self.bounds_measure()); + let r = anchors.or_else(|| self.transform_box(&st.selection.objects).map(|b| b.rect))?; + Some((r.width(), r.height())) + } +} + +fn readout(s: &mut Session, _: &Value) -> Result { + let mut out = json!({ "tool": s.tool_id() }); + if let Some((w, h)) = s.info_size() { + out["width"] = json!(w); + out["height"] = json!(h); + } + if let Some(r) = s.tool_readout() { + out["readout"] = r.to_json(); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + use crate::{Session, ViewInfo}; + + fn view() -> ViewInfo { + ViewInfo { smart_guides: false, snap_to_point: false, ..Default::default() } + } + + /// A 100 by 60 rectangle at (50, 50), selected, with a 10 pt stroke. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({ "ids": [id] })).unwrap(); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + s + } + + fn tool(s: &mut Session, id: &str) { + s.select_tool(id, view()).unwrap(); + } + + fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), view()).unwrap(); + } + + fn info(s: &mut Session) -> serde_json::Value { + s.execute("info.readout", &json!({})).unwrap() + } + + fn near(v: &serde_json::Value, want: f64) -> bool { + v.as_f64().is_some_and(|v| (v - want).abs() < 1e-6) + } + + #[test] + fn the_size_follows_use_preview_bounds() { + let mut s = session(); + let i = info(&mut s); + assert!(near(&i["width"], 100.0) && near(&i["height"], 60.0), "{i}"); + assert!(i.get("readout").is_none(), "{i}"); + s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": true})).unwrap(); + let i = info(&mut s); + assert!(near(&i["width"], 110.0) && near(&i["height"], 70.0), "{i}"); + s.execute("select.none", &json!({})).unwrap(); + let i = info(&mut s); + assert!(i.get("width").is_none() && i.get("height").is_none(), "{i}"); + } + + #[test] + fn a_move_reads_how_far_it_has_gone_until_the_release() { + let mut s = session(); + tool(&mut s, "selection"); + pointer(&mut s, PointerKind::Down, 100.0, 80.0); + assert!(info(&mut s).get("readout").is_none()); + pointer(&mut s, PointerKind::Drag, 130.0, 40.0); + let i = info(&mut s); + let r = &i["readout"]; + assert_eq!(r["kind"], "drag", "{i}"); + assert!(near(&r["dx"], 30.0) && near(&r["dy"], -40.0) && near(&r["distance"], 50.0), "{i}"); + assert!(near(&r["angle"], 53.13010235415598), "{i}"); + pointer(&mut s, PointerKind::Up, 130.0, 40.0); + let i = info(&mut s); + assert!(i.get("readout").is_none(), "{i}"); + assert!(near(&i["width"], 100.0), "{i}"); + } + + #[test] + fn each_transform_tool_reads_its_drag() { + for (id, check) in [ + ("scale", (|r| r["kind"] == "scale" && near(&r["width"], 200.0) && near(&r["height"], 200.0)) as fn(&serde_json::Value) -> bool), + ("rotate", |r| r["kind"] == "rotate" && near(&r["center"][0], 100.0) && near(&r["center"][1], 80.0) && near(&r["angle"], -90.0)), + ("reflect", |r| r["kind"] == "reflect" && near(&r["center"][0], 100.0) && near(&r["angle"], 0.0)), + ("shear", |r| { + r["kind"] == "shear" + && near(&r["center"][0], 100.0) + && near(&r["center"][1], 80.0) + && near(&r["axis"], 0.0) + && near(&r["angle"], 45.0) + }), + ] { + let mut s = session(); + tool(&mut s, id); + // The reference point is the box's center (100, 80). + let from = if id == "shear" { (100.0, 60.0) } else { (120.0, 100.0) }; + pointer(&mut s, PointerKind::Down, from.0, from.1); + let to = match id { + "scale" => (140.0, 120.0), + "rotate" => (80.0, 100.0), + "reflect" => (160.0, 80.0), + _ => (120.0, 60.0), + }; + pointer(&mut s, PointerKind::Drag, to.0, to.1); + let i = info(&mut s); + assert_eq!(i["tool"], id); + assert!(check(&i["readout"]), "{id}: {i}"); + pointer(&mut s, PointerKind::Up, to.0, to.1); + assert!(info(&mut s).get("readout").is_none(), "{id}"); + } + } + + #[test] + fn the_paintbrush_reads_its_brush() { + let mut s = session(); + tool(&mut s, "paintbrush"); + let i = info(&mut s); + assert_eq!(i["readout"], json!({"kind": "brush", "brush": null}), "{i}"); + } + + #[test] + fn a_parameter_is_refused() { + let mut s = session(); + let e = s.execute("info.readout", &json!({"tool": "scale"})).unwrap_err().to_string(); + assert!(e.contains("tool"), "{e}"); + } +} diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 114f5b4f61..46231dcd0d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -495,14 +495,14 @@ fn at_param(p: &Value) -> Result> { /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with -/// `image_xf`), also for an image alone in a clip group when the new art is an image clipped by -/// its file's clipping path (`clipped`); other objects keep their centre. -fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, clipped: bool) -> Affine { +/// `image_xf`), also for an image alone in a clip group when the new art is an image, clipped by +/// its file's clipping path or not (`new_image`); other objects keep their centre. +fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, new_image: bool) -> Affine { // An image alone in a clip group (as a PSD or PSB file's clipping path places it, - // [`fileio::file_clipped`]) is measured as the image when the new art brings its own clip - // group; a DCS file's plates are not. + // [`fileio::file_clipped`]) is measured as the image when the new art is an image; a DCS + // file's plates are not. let image = match &old.kind { - NodeKind::Group { children, clip: true } if clipped => match &children[..] { + NodeKind::Group { children, clip: true } if new_image => match &children[..] { [path, image] if matches!(path.kind, NodeKind::Path { clipping: true, .. }) => match &image.kind { NodeKind::Image(im) if d.images.get(&im.key).is_none_or(|b| b.separated().is_none()) => image, _ => old, @@ -681,7 +681,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } let natural = loaded.natural; let m = match (old_node, rect) { - (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))), + (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(..))), (None, Some(r)) => fit(natural, r), // Its artboard's bottom-left corner on the target's. (None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)), diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 2b1826328e..67d0fec2a3 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -206,6 +206,7 @@ pub(super) const ALLOWED: &[&str] = &[ "imageTrace.makeAndExpand", "imageTrace.release", "imageTrace.setView", + "info.readout", "layer.clippingMask.toggle", "layer.collectInNew", "layer.delete", @@ -693,6 +694,7 @@ pub(super) const ALLOWED: &[&str] = &[ "variable.makeTextDynamic", "variable.makeVisibilityDynamic", "variable.rename", + "variable.sort", "variable.unbind", ]; diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 11c4744f47..e54d68204e 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex); may be empty: an empty row recolours nothing, adds no library swatch and gives `group` no colour], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded and empty rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -1614,8 +1614,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(k) => super::swatchlib::library_swatches(s, k).unwrap_or_default(), None => vec![], }; - let row_swatches: Vec> = - rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // A row with no current colors recolors nothing, so its swatch does not join the document. + let row_swatches: Vec> = rows + .iter() + .map(|r| r.new_color().filter(|_| !r.from.is_empty()).and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())) + .collect(); // While an object of a dynamic symbol instance's art is selected, the selection is refused, as // `selected_roots` refuses it. super::edit::refuse_symbol_part(s)?; @@ -1657,7 +1660,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) }) .collect::>()?, - None => rows.iter().filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(), + // A row with no current colors gives the group no color. + None => rows.iter().filter(|r| !r.from.is_empty()).filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(), }; let rename = text_param(p, "rename", C)?; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a73c16448b..cba6199e97 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -330,18 +330,19 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn list(s: &mut Session, _p: &Value) -> Result { + let xml_id = s.prefs.identify_objects_by == "xmlId"; let st = s.doc()?; - let variables: Vec = st - .doc - .variables - .variables - .iter() + let vars = &st.doc.variables.variables; + let variables: Vec = variable_order(st, xml_id) + .into_iter() + .filter_map(|i| vars.get(i)) .map(|v| { let bindings = st.doc.variables.objects_of(&v.name).len(); json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) }) .collect(); - Ok(json!({"variables": variables, "locked": st.doc.variables.locked})) + let sort = json!({"by": st.variables_sort.by.id(), "descending": st.variables_sort.descending}); + Ok(json!({"variables": variables, "locked": st.doc.variables.locked, "sort": sort})) } /// Lock Variables: without `locked` it toggles. Setting the state the document already has @@ -417,6 +418,120 @@ fn highlight(s: &mut Session, p: &Value) -> Result { Ok(json!({"variable": name})) } +/// The column the Variables panel sorts its list by (`variable.sort`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VariableSortKey { + /// The order the variables were defined in. + #[default] + Defined, + /// The Variables column: the variable's name. + Name, + /// The Objects column: the name of the object the variable is bound to. + Object, + /// The type column: text, image, graph, visibility, then variables of unknown kind. + Type, +} + +impl VariableSortKey { + /// The values `variable.sort` takes for `by`, in the order of [`Self::id`]. + const IDS: [&'static str; 4] = ["name", "object", "type", "none"]; + + /// The value `variable.sort` takes for `by` and `variable.list` reports. + pub fn id(self) -> &'static str { + match self { + Self::Name => "name", + Self::Object => "object", + Self::Type => "type", + Self::Defined => "none", + } + } + + fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "object" => Some(Self::Object), + "type" => Some(Self::Type), + "none" => Some(Self::Defined), + _ => None, + } + } +} + +/// How the Variables panel orders its list: a column, and whether the order is reversed. Panel +/// state, not saved and not an undo step. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VariableSort { + pub by: VariableSortKey, + pub descending: bool, +} + +/// The place of a variable's kind in the order the type column sorts by. +fn kind_rank(kind: VariableKind) -> u8 { + match kind { + VariableKind::Text => 0, + VariableKind::Image => 1, + VariableKind::Graph => 2, + VariableKind::Visibility => 3, + VariableKind::Unknown => 4, + } +} + +/// The Variables panel's rows in its sort order, as indices into the document's variables. +/// Names compare without regard to case. Sorted by object, a variable bound to nothing comes +/// first and one bound to several objects comes after those bound to one; `xml_id` names objects +/// by their XML ids, as the panel does with Identify Objects By set to XML ID. Ties go by name. +pub fn variable_order(st: &crate::DocState, xml_id: bool) -> Vec { + let vars = &st.doc.variables.variables; + let sort = st.variables_sort; + let mut order: Vec = (0..vars.len()).collect(); + if sort.by != VariableSortKey::Defined { + order.sort_by_cached_key(|&i| { + let v = &vars[i]; + let (rank, label) = match sort.by { + VariableSortKey::Object => match st.doc.variables.objects_of(&v.name).as_slice() { + [] => (0, String::new()), + [one] => (1, st.doc.node(*one).map(|n| n.identify_label(xml_id)).unwrap_or_default()), + _ => (2, String::new()), + }, + VariableSortKey::Type => (kind_rank(v.kind), String::new()), + VariableSortKey::Name | VariableSortKey::Defined => (0, String::new()), + }; + (rank, label.to_lowercase(), label, v.name.to_lowercase(), v.name.clone()) + }); + } + if sort.descending { + order.reverse(); + } + order +} + +/// Sort the Variables panel's list. Panel state, like `variable.highlight`: not saved and not an +/// undo step. Setting the order the panel already has changes nothing. +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.sort"; + let key = "by"; + let by = match p.get(key) { + None => return Err(bad(C, format!("missing `{key}`"))), + Some(v) => v + .as_str() + .and_then(VariableSortKey::parse) + .ok_or_else(|| bad(C, format!("`{key}` must be one of {}", VariableSortKey::IDS.join(", "))))?, + }; + // A `descending` that is given must be true or false: null is refused, not read as omitted. + let key = "descending"; + let descending = match p.get(key) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`{key}` must be true or false")))?, + }; + let sort = VariableSort { by, descending }; + let st = s.doc_mut()?; + if st.variables_sort != sort { + st.variables_sort = sort; + st.revision += 1; + } + Ok(json!({"by": by.id(), "descending": descending})) +} + fn dataset_new(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.new"; let name = var_name(p, C)?; @@ -726,7 +841,15 @@ pub fn specs() -> Vec { variables_unlocked, rename ), - cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts, and whether they are locked → {variables, locked}", has_doc, list), + cmd!( + query "variable.list", + "List Variables", + [], + None, + "{} → variables with kinds and binding counts in the Variables panel's order, whether they are locked, and the panel's sort (as variable.sort sets it) → {variables, locked, sort: {by, descending}}", + has_doc, + list + ), cmd!( "variable.makeTextDynamic", "Make Text Dynamic", @@ -799,6 +922,15 @@ pub fn specs() -> Vec { has_doc, highlight ), + cmd!( + "variable.sort", + "Sort Variables", + [], + None, + "{by: name|object|type|none, descending?: bool} sort the Variables panel's list (a click on a column header) by variable name, by the name of the bound object, or by type (text, image, graph, visibility, then unknown); `none` is the order the variables were defined in, and `descending` reverses the order. Names compare without regard to case; by object, unbound variables come first and variables bound to several objects last, and ties go by name. Panel state: not saved and not an undo step; variable.list reports it → {by, descending}", + has_doc, + sort + ), cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), cmd!( "dataset.set", diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 780616c07a..9cbdfb6827 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -29,6 +29,9 @@ pub struct DocState { /// Bound Object act on. Panel state, not art selection: not saved, not undoable /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. pub variables_highlight: Option, + /// The order of the Variables panel's list: the column it is sorted by, and whether it is + /// reversed. Panel state: not saved, not undoable (`variable.sort`). + pub variables_sort: cmd::VariableSort, /// Isolation mode container. pub isolation: Option, /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape @@ -316,6 +319,7 @@ impl DocState { active_layer, layer_rows: vec![], variables_highlight: None, + variables_sort: Default::default(), isolation: None, global_edit: None, similar_outlines: Default::default(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 890f8622f8..8b62c8c8e3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::clipboard::{Clipboard, SourceLayer}; pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; +pub use cmd::graph::{GraphTypeSubject, calculated_axes as graph_calculated_axes, graph_type_subject}; pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; diff --git a/crates/engine/src/tests/clippaint.rs b/crates/engine/src/tests/clippaint.rs index de637870a3..1355c11b7d 100644 --- a/crates/engine/src/tests/clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -84,6 +84,63 @@ fn release_and_ungroup_keep_the_clipping_paths_paint() { } } +/// Pixels of `doc` rendered on white that are mostly blue and mostly green. +fn blue_and_green(doc: &Document) -> (usize, usize) { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let r = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts); + let (mut blue, mut green) = (0, 0); + for y in 0..200 { + for x in 0..200 { + let p = r.pixel(x, y); + blue += usize::from(p[2] > 200 && p[0] < 60 && p[1] < 60); + green += usize::from(p[1] > 200 && p[0] < 60 && p[2] < 60); + } + } + (blue, green) +} + +#[test] +fn painted_type_clipping_inside_a_group_with_geometry_effects_keeps_its_paint() { + // The canvas and the SVG and PDF output paint the type's fill and stroke as they did before + // the effect. Type that stays type beside the clip group takes the second Transform case + // through baking that keeps type. + let cases = + [("distort.transform", json!({"moveH": 5}), false), ("distort.transform", json!({"moveH": 5}), true), ("distort.roughen", json!({}), false)]; + for (effect, params, other_type) in cases { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + rect(&mut s, 0.0, 30.0, "#ff0000"); + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Clip", "size": 72})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.all", &json!({})).unwrap(); + let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [t.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [t.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2, "ids": [t.0]})).unwrap(); + let before = blue_and_green(&s.doc().unwrap().doc); + let mut ids = vec![clip.0]; + if other_type { + ids.push(s.execute("text.create", &json!({"x": 10, "y": 190, "text": "x", "size": 10})).unwrap()["id"].as_u64().unwrap()); + } + let group = NodeId(s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [group.0], "item": null})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let (blue, green) = blue_and_green(&doc); + assert!( + blue * 2 > before.0 && green * 2 > before.1, + "{effect} {other_type}: the canvas paints the type's fill and stroke: {blue} {green}, {before:?} before" + ); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap(); + for (what, out) in [("SVG", vectorcraft_svg::import(&svg).unwrap()), ("PDF", vectorcraft_pdf::import(&pdf).unwrap())] { + let (b, g) = blue_and_green(&out); + assert!( + b.abs_diff(blue) * 10 < blue && g.abs_diff(green) * 5 < green, + "{effect} {other_type}, {what}: {b} {g} against the canvas's {blue} {green}" + ); + } + } +} + #[test] fn type_as_a_clipping_path_paints_its_characters() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 90e7822ef8..390248f2a9 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -303,6 +303,62 @@ fn methods_and_bad_params() { assert!(s.execute("recolor.apply", &json!({"map": {}})).is_err(), "needs art or a group"); } +/// A row with no current colors (Recolor Artwork's New Row) recolors nothing, and records no +/// undo step on its own; beside it the other rows recolor as before, and the weights and +/// randomize keep it. A row without `from`, or with a bad current color, is still refused. +#[test] +fn an_empty_row_recolors_nothing() { + let mut s = session(); + let red = rect(&mut s, json!("#ff0000")); + let blue = rect(&mut s, json!("#0000ff")); + select_all(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let empty = json!({"from": [], "to": "rgb 0 255 0"}); + let r = run(&mut s, "recolor.apply", json!({"map": [empty]})); + assert_eq!(r["changed"], json!(0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let map = json!([empty, {"from": ["rgb 0 0 255"], "to": "rgb 255 255 0"}]); + let w = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(w["map"], map, "the rows as given"); + assert_eq!(w["shares"], json!([0.0, 100.0])); + let shuffled = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 1})); + assert_eq!(shuffled["map"][0]["from"], json!([])); + run(&mut s, "recolor.apply", json!({"map": map})); + assert_eq!((fill(&s, red).to_hex(), fill(&s, blue).to_hex()), ("#ff0000".into(), "#ffff00".into())); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + for bad in [json!([{"to": "rgb 0 255 0"}]), json!([{"from": ["nonsense"], "to": "rgb 0 255 0"}])] { + assert!(s.execute("recolor.apply", &json!({"map": bad})).is_err(), "{bad}"); + } +} + +/// An empty row adds no library swatch with `limitTo`, and gives a rewritten group no color: the +/// group keeps one color per row that holds current colors. +#[test] +fn an_empty_row_adds_no_swatch_and_no_group_color() { + let mut s = session(); + rect(&mut s, json!("#ff0000")); + rect(&mut s, json!("#0000ff")); + select_all(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let swatches = s.doc().unwrap().doc.swatches.len(); + let r = run(&mut s, "recolor.apply", json!({"map": [{"from": [], "to": "#ff0033"}], "limitTo": "web-safe-216"})); + assert_eq!(r["swatches"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc.swatches.len(), swatches); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + run(&mut s, "swatch.newGroup", json!({"name": "Pair", "colors": ["#ff0000", "#0000ff"]})); + select_all(&mut s); + let map = json!([ + {"from": [], "to": "rgb 0 255 0"}, + {"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}, + {"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"}, + ]); + run(&mut s, "recolor.apply", json!({"map": map, "group": "Pair"})); + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Pair").unwrap(); + let hexes: Vec = g.swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect(); + assert_eq!(hexes, ["#ff0000", "#0000ff"]); +} + #[test] fn randomize_shuffles_new_colours_and_skips_excluded_rows() { let mut s = session(); diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index fd8f2c7e24..e8623900fd 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -45,6 +45,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "neon-glow" && l["name"] == "Neon & Glow" && l["count"] == 6), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -69,6 +70,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; assert_eq!(texture_names, expected); + let neon = run(&mut s, "graphicStyle.library", json!({"library": "neon-glow"})); + let neon_names: Vec<&str> = neon["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(neon_names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -419,3 +423,53 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { } } } + +#[test] +fn neon_and_glow_library_adds_and_applies_multi_stroke_styles() { + let mut s = session(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + let at = libs.iter().position(|l| l["id"] == "neon-glow").expect("neon-glow listed"); + assert_eq!(libs[at]["name"], "Neon & Glow"); + assert_eq!(libs[at]["count"], 6); + + let (_, lib) = cmd::stylelib::library(&s, "neon-glow").unwrap(); + assert_eq!(lib.len(), 6); + + let want = [ + ("Cyan Neon", 2, "stylize.dropShadow"), + ("Magenta Laser", 2, "stylize.outerGlow"), + ("Amber Tube", 2, "stylize.outerGlow"), + ("Electric Lime", 2, "stylize.outerGlow"), + ("Pulse Violet", 2, "stylize.innerGlow"), + ("Cobalt Phosphor", 2, "blur.gaussian"), + ]; + + for (i, (name, stroke_count, first_effect)) in want.iter().enumerate() { + let id = rect(&mut s, 10.0 + 30.0 * i as f64, 15.0, 25.0, 25.0); + let before_undo = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name))); + assert_eq!(undo_len(&s), before_undo + 1, "{name}"); + + let node = doc(&s).node(id).unwrap(); + let strokes = node.appearance.items.iter().filter(|it| matches!(it, vectorcraft_doc::AppearanceItem::Stroke(_))).count(); + assert_eq!(strokes, *stroke_count, "{name}"); + assert_eq!(node.appearance.effects[0].id, *first_effect, "{name}"); + } + + // Adding existing style dedupes and creates no new undo step. + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": "Cyan Neon"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Cyan Neon"])); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + + // Round-trip through serialize / open + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "neon.vectorcraft", "dataBase64": b64})); + for (name, _, _) in want { + let d = doc(&s); + let st = d.graphic_style(name).unwrap(); + assert!(st.same_look(lib.style(name).unwrap()), "{name}"); + } +} diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index bb5b73b2f2..7893b3d27e 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -3382,3 +3382,93 @@ fn an_svg_without_readable_variable_data_opens_its_art() { assert!(d.variables.variables.is_empty()); assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); } + +/// Five variables of three kinds, defined out of every order: `beta` (text, on type named Mike), +/// `Alpha` (visibility, on a rectangle named apple), `gamma` (image, bound to nothing), `Delta` +/// (visibility, on two rectangles) and `Echo` (text, on type named Bravo). +fn sortable_variables() -> Session { + let mut s = session(); + let mike = text(&mut s, "m"); + let bravo = text(&mut s, "b"); + let apple = rect(&mut s); + let (r1, r2) = (rect(&mut s), rect(&mut s)); + for (id, name) in [(mike, "Mike"), (bravo, "Bravo"), (apple, "apple")] { + s.execute("layer.setProps", &json!({"id": id.0, "name": name})).unwrap(); + } + for (name, kind, ids) in [ + ("beta", "text", vec![mike]), + ("Alpha", "visibility", vec![apple]), + ("gamma", "image", vec![]), + ("Delta", "visibility", vec![r1, r2]), + ("Echo", "text", vec![bravo]), + ] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + if !ids.is_empty() { + s.execute("variable.bind", &json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + } + } + s +} + +fn listed_names(s: &mut Session) -> Vec { + let list = s.execute("variable.list", &json!({})).unwrap(); + list["variables"].as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// `variable.sort` orders the Variables panel's list, which `variable.list` follows, by each +/// column: names without regard to case; by object, unbound first and several objects last; by +/// type, text, image, graph, visibility. `descending` reverses each order, and `none` goes back +/// to the order the variables were defined in. +#[test] +fn variable_sort_orders_the_list_by_each_column() { + let mut s = sortable_variables(); + let defined = ["beta", "Alpha", "gamma", "Delta", "Echo"]; + assert_eq!(listed_names(&mut s), defined); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["sort"], json!({"by": "none", "descending": false})); + for (by, order) in [ + ("name", ["Alpha", "beta", "Delta", "Echo", "gamma"]), + ("object", ["gamma", "Alpha", "Echo", "beta", "Delta"]), + ("type", ["beta", "Echo", "gamma", "Alpha", "Delta"]), + ("none", defined), + ] { + let r = s.execute("variable.sort", &json!({"by": by})).unwrap(); + assert_eq!(r, json!({"by": by, "descending": false})); + assert_eq!(listed_names(&mut s), order, "{by}"); + s.execute("variable.sort", &json!({"by": by, "descending": true})).unwrap(); + let reversed: Vec<&str> = order.iter().rev().copied().collect(); + assert_eq!(listed_names(&mut s), reversed, "{by}, descending"); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["sort"], json!({"by": by, "descending": true})); + } +} + +/// The sort is panel state: it is not an undo step and does not count as a change to save, and +/// setting the order the list already has does not redraw anything. A bad `by` or `descending` +/// is an error that leaves the order as it was. +#[test] +fn variable_sort_refuses_bad_params_and_is_not_an_undo_step() { + let mut s = sortable_variables(); + let steps = s.doc().unwrap().history.undo.len(); + let dirty = s.doc().unwrap().is_dirty(); + s.execute("variable.sort", &json!({"by": "name", "descending": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step"); + assert_eq!(s.doc().unwrap().is_dirty(), dirty); + let revision = s.doc().unwrap().revision; + s.execute("variable.sort", &json!({"by": "name", "descending": true})).unwrap(); + assert_eq!(s.doc().unwrap().revision, revision, "the same order changes nothing"); + + for (p, error) in [ + (json!({}), "missing `by`"), + (json!({"by": null}), "`by` must be one of name, object, type, none"), + (json!({"by": "name", "descending": null}), "`descending` must be true or false"), + (json!({"by": "size"}), "`by` must be one of name, object, type, none"), + (json!({"by": "Name"}), "`by` must be one of name, object, type, none"), + (json!({"by": 1}), "`by` must be one of name, object, type, none"), + (json!({"by": "type", "descending": "yes"}), "`descending` must be true or false"), + ] { + let e = s.execute("variable.sort", &p).unwrap_err().to_string(); + assert!(e.contains(error), "{p}: {e}"); + } + let sort = s.doc().unwrap().variables_sort; + assert_eq!((sort.by, sort.descending), (crate::cmd::VariableSortKey::Name, true), "errors leave the order alone"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f6bc163e65..662af8c248 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1103,6 +1103,12 @@ impl Session { self.pointer_held } + /// What the Info panel shows for the active tool as it is now + /// ([`vectorcraft_tools::Tool::readout`]), in the view of the last pointer event. + pub fn tool_readout(&mut self) -> Option { + self.with_tool_cx(self.last_view, |t, cx| t.readout(cx)) + } + /// Is the active tool moving, scaling or rotating the selection with a drag? pub fn tool_transforming(&self) -> bool { self.tool.transforming() diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index e91e12a4bd..1323ea950b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10455,7 +10455,8 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { } /// Envelopes the reader would open in another shape, or not at all, are written as their distorted -/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// art with a warning, and open as that art: an envelope of type with text wrap options, one of +/// mirrored type (written as its outlines), one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object /// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. @@ -10470,10 +10471,24 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { f(&mut doc.node_mut(id).unwrap().kind); doc }; - let typed = change(&|k| { + let type_node = |id: u64| { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + Node::new(vectorcraft_doc::NodeId(id), NodeKind::Text(Box::new(t))) + }; + let wrapped_type = change(&|k| { if let NodeKind::Envelope { content, .. } = k { - let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); - content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + let mut t = type_node(9_000); + t.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false }); + content.push(Arc::new(t)); + } + }); + let mirrored_type = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let mut t = type_node(9_001); + if let NodeKind::Text(t) = &mut t.kind { + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 50.0]); + } + content.push(Arc::new(t)); } }); let turned = change(&|k| { @@ -10512,7 +10527,8 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { } }); let cases = [ - ("type", typed), + ("type with text wrap options", wrapped_type), + ("mirrored type, written as its outlines", mirrored_type), ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), @@ -10537,6 +10553,91 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec Node { + let mut envelope = envelope_node(doc, kind, 50.0, editing, vectorcraft_doc::live::EnvelopeOptions::NEW); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(104.0, 62.0), "Type", style(24.0, Color::rgb(0.0, 0.0, 1.0))); + let text = text_node(doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(text)); + envelope +} + +/// A 200 × 100 document of `envelope` alone. +fn alone(envelope: Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// `doc` written, then read back through its editing copy beside a page that draws as `doc` does, +/// its live objects as their art: the objects on its first layer, the writer's warnings and the +/// reader's notes. +fn read_with_page(doc: &Document) -> (Vec>, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let mut page = doc.clone(); + let ids: Vec = page.layers[0].children().into_iter().flatten().map(|n| n.id).collect(); + for id in ids { + if let Some(n) = page.node_mut(id) { + *n = vectorcraft_effects::expand_live_deep(Some(doc), n); + } + } + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, false, 1.0); + assert!(layers, "{notes:?}"); + (read.layers[0].children().cloned().unwrap_or_default(), warnings, notes) +} + +/// The type in `nodes` and their groups, with whether it shows. +fn type_in(nodes: &[Arc]) -> Vec<(String, bool)> { + let mut out = vec![]; + for n in nodes { + match &n.kind { + NodeKind::Text(t) => out.push((t.plain_text(), n.visible)), + NodeKind::Group { children, .. } => out.extend(type_in(children)), + _ => {} + } + } + out +} + +/// An envelope whose contents hold type keeps the type as type: a warp, a mesh and a top object +/// envelope, with Edit Contents off and on, open as the same live envelope with the type shown +/// and editable in its contents beside the rest of them, no copy of the type left on the layer, +/// and again when saved as they opened. +#[test] +fn an_envelope_of_type_is_read_back_live_with_its_type() { + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + for round in ["saved", "saved again"] { + doc = alone(envelope); + let (top, warnings, notes) = read_with_page(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES || w == writer::TYPE), "{what}, {round}: {warnings:?}"); + assert!( + !notes.iter().any(|n| n == crate::import::ENVELOPE_TYPE_DRAWN || n == crate::import::ENVELOPE_DRAWN), + "{what}, {round}: {notes:?}" + ); + let [back] = top.as_slice() else { panic!("{what}, {round}: {top:#?} {notes:?}") }; + let NodeKind::Envelope { content, kind: got, editing: e, fidelity, .. } = &back.kind else { + panic!("{what}, {round}: {back:#?} {notes:?}") + }; + assert_eq!((*e, *fidelity), (editing, 50.0), "{what}, {round}"); + assert!(same_envelope_kind(&kind, got), "{what}, {round}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}, {round}"); + assert_eq!(type_in(content), [("Type".to_string(), true)], "{what}, {round}"); + let colors: Vec> = + content.iter().filter(|c| !matches!(c.kind, NodeKind::Text(_))).map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}, {round}"); + envelope = (**back).clone(); + } + } + } +} + /// A top object over (30, 10)-(170, 90) whose top side is `top` straight segments, falling and /// rising by 10 pt between its anchors, and whose other sides are one straight segment each. fn uneven_top_object(top: usize) -> kurbo::BezPath { @@ -10610,6 +10711,86 @@ fn a_top_object_with_uneven_sides_is_read_back_live() { } } +/// An envelope of type is written live with room for every object the reader reads of it, the +/// copy of its type it reads in the envelope's dictionary included. Written as its art, it takes +/// its type's story out of the text document with it: where its record doesn't fit within the +/// object limit, and where other type in its contents is written as its outlines (mirrored type). +/// The file then has no text document. +#[test] +fn an_envelope_of_type_written_as_its_art_leaves_no_story() { + use vectorcraft_doc::live::EnvelopeKind; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + let doc = alone(envelope); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("AIDeformOptionsKey") && all.contains("%AI11_BeginTextDocument"), "editing {editing}: {all}"); + let read = crate::import::objects_read(&program).unwrap(); + let out = export_within(&doc, &opts(), read).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + let short = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + assert!(!short.contains("AIDeformOptionsKey") && !short.contains("%AI11_BeginTextDocument"), "editing {editing}: {short}"); + } + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind, false); + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 40.0), "Back", style(12.0, Color::BLACK)); + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 40.0]); + let mirrored = text_node(&mut doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(mirrored)); + let out = export_ai_editing(&alone(envelope), &opts()).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::ENVELOPES) && !out.warnings.iter().any(|w| w == writer::TYPE), "{:?}", out.warnings); + assert!(!text.contains("AIDeformOptionsKey") && !text.contains("%AI11_BeginTextDocument"), "{text}"); +} + +/// Type in an envelope that is a symbol's art, or a frame of a thread, keeps the envelope written +/// as its art: the reader would open its type as drawn art. +#[test] +fn an_envelope_of_type_in_a_symbol_or_a_thread_is_written_as_its_art() { + use vectorcraft_doc::live::EnvelopeKind; + use vectorcraft_doc::{TextKind, TextObject}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let art = typed_envelope_node(&mut doc, kind.clone(), false); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Bent", Affine::translate((10.0, 5.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{:?} {notes:?}", back.symbols)); + let mut live = 0; + symbol.art.walk(&mut |n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 0, "{notes:?}"); + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind, 50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW); + let area = |r: Rect, s: &str| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, s, style(10.0, Color::BLACK)); + (t.kind, t.xf) = (TextKind::Area { frame: shapes::rectangle(r) }, Affine::IDENTITY); + t + }; + let first = text_node(&mut doc, area(Rect::new(50.0, 30.0, 150.0, 70.0), "One two three four")); + let second = text_node(&mut doc, area(Rect::new(10.0, 85.0, 190.0, 95.0), "")); + doc.text_threads.push(vec![first.id, second.id]); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(first)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{text}"); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); +} + /// A top object with a top side of 50 segments over a bottom side of one is written live with a grid /// of 50 columns, the most the reader reads. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 15bd97d3b5..5866bd5e44 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -204,6 +204,12 @@ pub(super) struct Writer<'a> { /// The `hidden` and `nest` values at which the objects of the opacity mask's art being written /// are written: objects there may be written with their art styles. mask_level: Option<(u32, u32)>, + /// The `hidden` and `nest` values at which the objects of the envelope contents being written + /// are written ([`Self::envelope`]): type there may be written as type. With them, whether the + /// contents are in the envelope's dictionary, where the reader reads a copy of each text object. + contents_level: Option<(u32, u32, bool)>, + /// How many text objects were written as their outlines ([`Self::text`]). + outlined: usize, /// The image just written was written as a placed file. placed: bool, /// The image being written is the copy of a styled object on the layers, on `%_` lines: linked, @@ -322,6 +328,8 @@ impl<'a> Writer<'a> { payloads: vec![], hidden: 0, mask_level: None, + contents_level: None, + outlined: 0, placed: false, styled_copy: false, typed: false, @@ -1414,9 +1422,10 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to - /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type - /// names the story of its thread, which its first frame holds. + /// text document can hold it, on the layers or in an envelope's contents, outside opacity + /// masks, pattern tiles, symbols' art and art cut to a clipping object; else its outlines + /// ([`Self::outlined_text`]). A frame of threaded area type names the story of its thread, + /// which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; // Dynamic Text is written as the sizes its layout draws the type at. @@ -1424,13 +1433,16 @@ impl<'a> Writer<'a> { let t = fitted.as_deref().unwrap_or(&**t); let doc = self.doc; let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + let contents = self.contents_level.filter(|(hidden, nest, _)| (*hidden, *nest) == (self.hidden, self.nest)); // Type that can't be a stub is written as its outlines, which count their own objects. - if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + if (self.hidden > 0 && contents.is_none()) || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + self.outlined += 1; return self.outlined_text(n, xy); } - // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for - // them, the type is left out before its story goes into the text document. - let stub = texts::stub_objects(t); + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]), and the copy the + // reader reads of it in an envelope's dictionary; without room for them, the type is left + // out before its story goes into the text document. + let stub = texts::stub_objects(t) + usize::from(contents.is_some_and(|(.., copied)| copied)); if !self.take_objects(stub) { return; } @@ -1444,6 +1456,7 @@ impl<'a> Writer<'a> { let Some(((story, frame), lost)) = placed else { // The text document can't hold it: the outlines count their own objects. self.objects -= stub; + self.outlined += 1; return self.outlined_text(n, xy); }; self.typed = true; diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index cae8993827..dbd906e7cd 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -20,14 +20,14 @@ use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, }; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, PathData}; use super::{MAX_NEST, Writer, Xy, hide}; use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; use crate::ps; -pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256 envelopes and images with a Warp, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256 envelopes and images with a Warp, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; /// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the /// reader's). @@ -58,6 +58,15 @@ fn holds_type(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) } +/// Is all the type in `n`, and in anything in it, type the reader can make an envelope's contents +/// of: none of it is a frame of threaded type or has text wrap options. +fn type_reads_back(n: &Node, doc: &Document) -> bool { + match &n.kind { + NodeKind::Text(_) => n.extras().wrap.is_none() && !doc.text_threads.iter().any(|th| th.contains(&n.id)), + _ => n.children().is_none_or(|c| c.iter().all(|k| type_reads_back(k, doc))), + } +} + /// The record of envelope `n` (its parts given), or `None` where the reader would open it in /// another shape than it has, or not as an envelope. fn record( @@ -73,7 +82,7 @@ fn record( // Distort Appearance on. let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; - if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept { return None; } let mut keys = String::new(); @@ -347,17 +356,24 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, impl Writer<'_> { /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity - /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, - /// writing nothing, where it is written as its art instead: where the reader would open it in - /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. Type + /// in its contents is written as type, which the reader makes the envelope's contents again. + /// False, writing nothing, where it is written as its art instead: where the reader would open it + /// in another shape, or with its type as drawn art (threaded type, type with text wrap options, + /// type written as its outlines), or some of it is left out for the object limit + /// ([`Self::take_objects`]). pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.checks_left == 0 { return false; } + if content.iter().any(|c| holds_type(c)) && !content.iter().all(|c| type_reads_back(c, self.doc)) { + return false; + } let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; let start = self.body.len(); let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + let (outlined, texts, warnings) = (self.outlined, self.texts.written(), self.warnings.len()); self.nest += 1; self.hidden += 1; // The group before the dictionary: the grid, or the contents while they are edited. @@ -365,7 +381,7 @@ impl Writer<'_> { let opened = self.open_group("u"); if opened { if *editing { - self.contents(content); + self.contents(content, false); } else { self.grid(record.grid.as_ref()); } @@ -383,23 +399,31 @@ impl Writer<'_> { if *editing { self.grid(record.grid.as_ref()); } else if self.open_group("u") { - self.contents(content); + self.contents(content, true); self.end_group("U", Xy::PLAIN); } let block = std::mem::replace(&mut self.body, outer); hide(&block, &mut self.body); (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + // Type in the contents written as its outlines would open as drawn art, not as type. + let outlines = self.outlined != outlined; self.hidden -= 1; self.nest -= 1; // The distorted art, which takes the envelope's lock, visibility, transparency, name and // opacity mask. self.state(n.locked, !n.visible); self.drawn_art(n, xy, None); - if !opened || self.left_out_since(charges) { + if !opened || outlines || self.left_out_since(charges) { let lines = self.body.split_off(start); self.give_back(charges, &lines); self.bound.truncate(bound); + // The stories of the type in the lines taken out go with them. + self.texts.take_back(texts); + // The warnings of art written as its art again are that art's. + if !self.left_out_since(charges) { + self.warnings.truncate(warnings); + } (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); return false; } @@ -408,11 +432,14 @@ impl Writer<'_> { true } - /// An envelope's contents. - fn contents(&mut self, content: &[Arc]) { + /// An envelope's contents, where type may be written as type ([`Self::text`]): `copied` when + /// they are in its dictionary, where the reader reads a copy of each text object. + fn contents(&mut self, content: &[Arc], copied: bool) { + let level = self.contents_level.replace((self.hidden, self.nest, copied)); for c in content { self.node(c); } + self.contents_level = level; } /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 8503500007..f1fb10af78 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -621,6 +621,15 @@ fn place_on(segments: &[[f64; 8]], f: f64) -> f64 { segments.len() as f64 } +/// Where the text document ended at some point ([`TextDoc::written`]). +#[derive(Clone, Copy)] +pub(in crate::ai_editing) struct TextMark { + at: (usize, usize, usize, usize, usize), + values: usize, + lost: bool, + planned: usize, +} + /// The text document being written. #[derive(Default)] pub(in crate::ai_editing) struct TextDoc { @@ -1023,6 +1032,24 @@ impl TextDoc { self.threaded.get(&id).copied() } + /// Where the text document written so far ends, for [`Self::take_back`]. + pub(in crate::ai_editing) fn written(&self) -> TextMark { + TextMark { at: self.mark(), values: self.doc_values, lost: self.lost, planned: self.planned.len() } + } + + /// Take the frames, stories and fonts added since `m` out again, with what they counted. Where + /// a thread was planned since, its frames name its story, so nothing is taken out. + pub(in crate::ai_editing) fn take_back(&mut self, m: TextMark) { + if self.planned.len() != m.planned { + return; + } + let (frames, stories, bytes, text, fonts) = m.at; + self.frames.truncate(frames); + self.stories.truncate(stories); + self.fonts.truncate(fonts); + (self.doc_bytes, self.text_bytes, self.doc_values, self.lost) = (bytes, text, m.values, m.lost); + } + /// Whether a thread written since the last call has attributes the text document leaves out. pub(in crate::ai_editing) fn take_lost(&mut self) -> bool { std::mem::take(&mut self.lost) diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index c791f7c459..6f77c141ac 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -24,7 +24,9 @@ //! from those layers alone, without its page. //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page -//! of a PostScript file without one. A file whose program can't be read (an operator the +//! of a PostScript file without one. A `.ai` in the legacy format ([`import_ai`]) that comes in +//! through its layers keeps the artboard its header gives, with its art outside it; an EPS comes in +//! on its page. A file whose program can't be read (an operator the //! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its //! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not //! included, as other apps write it), or with its art on one layer when it is in the legacy format @@ -82,8 +84,6 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -#[cfg(test)] -pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; @@ -92,6 +92,8 @@ pub(crate) use ai::{ MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; +#[cfg(test)] +pub(crate) use ai::{ENVELOPE_DRAWN, ENVELOPE_TYPE_DRAWN}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; @@ -279,7 +281,20 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// and data sets) read in `names`. The type of the document it gives that is set in families that /// aren't available shows in the fallback font, and a warning names them. pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { - let mut r = read_names(bytes, editing_data, trust, names)?; + read_checked(bytes, editing_data, trust, names, false) +} + +/// [`import_names`] for a `.ai` (or `.ait`) in PostScript form: a program in the legacy format +/// that comes in through its layers keeps the artboard its header gives, with its art outside it, +/// where an EPS comes in on its page, its art's box. A `.ai` of versions 3 and 4, which doesn't +/// give its artboard's size, has a US Letter artboard around its template box's center. +pub fn import_ai(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { + read_checked(bytes, editing_data, trust, names, true) +} + +/// [`import_names`] (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_checked(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { + let mut r = read_names(bytes, editing_data, trust, names, ai_file)?; let missing = missing_fonts(&r.document); if !missing.is_empty() { r.warnings.push(format!("{MISSING_FONTS_NOTE}: {}", missing.join(", "))); @@ -314,8 +329,8 @@ fn missing_fonts(d: &Document) -> Vec { missing } -/// [`import_names`] before the type's fonts are checked. -fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { +/// [`import_names`] before the type's fonts are checked (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -323,7 +338,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // printed). if editing_data && dsc.own_legacy - && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names) + && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, ai_file, names) { return Ok(Imported { document, warnings, preview: false }); } @@ -343,7 +358,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, names)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, ai_file, names)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), @@ -351,7 +366,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the // prolog defining its operators without including it): its layers, which the program has, or // its art on one layer when it has none (Illustrator 3). - let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() { + let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, ai_file, names)).flatten() { Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), None => why, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 4595061151..1a73f64a50 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -55,8 +55,8 @@ pub use read::{ palette, read_beside, read_in, symbols, variables, }; pub(crate) use read::{ - MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, - symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_program, read_style_list, slot_frame, slot_of, + style_moves_with, symbol_art_member, }; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bc1b82c22d..d32ae4c177 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -21,9 +21,10 @@ //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. //! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display -//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre -//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format -//! another app wrote) the bounding box. +//! options, else `%AI3_Cropmarks`, else the art size (`%AI5_ArtSize`; VectorCraft's own default, +//! US Letter, in a `.ai` of versions 3 and 4, which don't give it) around the template box's center (`%AI3_TemplateBox`, +//! `%%TemplateBox` in older files), which the version 7 specification (§2.2) puts at the +//! artboard's center, else (a file in the legacy format another app wrote) the bounding box. //! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's //! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of //! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. @@ -273,12 +274,28 @@ pub(crate) fn read_over_page(data: &[u8], large_canvas: bool, names: NameEncodin read_with(data, large_canvas, true, names) } -fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { +/// [`read_in`] (or [`read_over_page`] when `page_art`) for the program of a `.ai` in the legacy +/// format: one of versions 3 and 4 that has a template box but no art size has a US Letter +/// artboard ([`Reader::sized_board`]). +pub(crate) fn read_program(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { let mut r = Reader::with_names(data, names); - r.large_canvas = large_canvas.then_some(1.0); - r.page_art = page_art; - r.run()?; - r.finish() + r.legacy_ai = true; + r.read_as(large_canvas, page_art) +} + +fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { + Reader::with_names(data, names).read_as(large_canvas, page_art) +} + +impl Reader<'_> { + /// The structure read, the declared large canvas scale applied when `large_canvas`, the page's + /// art replacing the objects not read when `page_art` ([`read_over_page`]). + fn read_as(mut self, large_canvas: bool, page_art: bool) -> Result { + self.large_canvas = large_canvas.then_some(1.0); + self.page_art = page_art; + self.run()?; + self.finish() + } } /// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`. @@ -645,6 +662,11 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// The data is the program of a `.ai` in the legacy format ([`read_program`]), not an EPS's: + /// without an art size, its artboard can be US Letter ([`Reader::sized_board`]). + legacy_ai: bool, + /// The header has `%AI5_FileFormat`, which files of version 5 and later write. + file_format: bool, /// `%AI24_LargeCanvasScale`: a Large Canvas document's editing data is written at 1/N scale /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). @@ -827,6 +849,8 @@ impl<'a> Reader<'a> { xml_id_bytes: 0, art_size: None, template_center: None, + legacy_ai: false, + file_format: false, large_canvas: None, page_art: false, canvas_scale: 1.0, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 0fe6b9da87..8ca3781717 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -242,10 +242,13 @@ impl<'a> Reader<'a> { && let Ok(n) = v.trim().parse::() { self.ruler_units = Some(n); + } else if self.frames.is_empty() && line.starts_with("AI5_FileFormat") { + self.file_format = true; } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { self.art_size = two(v); } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) { - self.template_center = two(v); + // The center of the box (a file without a template writes it with no width or height). + self.template_center = four(v).map(|[x0, y0, x1, y1]| ((x0 + x1) / 2.0, (y0 + y1) / 2.0)).or_else(|| two(v)); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { self.large_canvas_scale(v); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ba6a8bd2de..091b440758 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -871,9 +871,15 @@ impl<'a> Reader<'a> { // ---- the document ---------------------------------------------------------------------- /// The artboard of a file without artboards or crop marks: its size around the template - /// box's centre (half a point off the grid). + /// box's center (half a point off the grid). The program of a `.ai` of versions 3 and 4 + /// ([`Reader::legacy_ai`], no `%AI5_FileFormat`), which don't record the artboard's size, has + /// VectorCraft's own default there, a US Letter artboard (612 × 792 pt); an EPS, or a newer + /// file without `%AI5_ArtSize`, has none (its page is its bounding box). fn sized_board(&self) -> Option { - let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); + let (cx, cy) = self.template_center?; + let [_, _, letter_w, letter_h] = crate::import::LETTER; + let letter = self.legacy_ai && !self.file_format; + let (w, h) = self.art_size.or(letter.then_some((letter_w, letter_h)))?; // Rounded out, but not off a whole point the decimals in the file only just miss. let near = |v: f64| (v - v.round()).abs() < 1e-6; let (left, top) = (cx - w / 2.0, cy + h / 2.0); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c469f02cd4..09368870e8 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -19,6 +19,16 @@ pub(super) enum Page { Art, /// A `.ai` is read on its (first and only) artboard. Artboard, + /// A `.ai` in the legacy format (PostScript, not an EPS): its page is its art's bounding box, as + /// an EPS's is, and its artboard is the one its header gives. + Program, +} + +impl Page { + /// Is the page the art's bounding box? + fn of_art(self) -> bool { + matches!(self, Page::Art | Page::Program) + } } /// How art moved from a page to its place in the layers' document transforms: its pattern tiles @@ -62,8 +72,12 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - let large_canvas = matches!(page, Page::Art); - let structure = if visible.is_some() { ai::read_over_page(data, large_canvas, names)? } else { ai::read_in(data, large_canvas, names)? }; + let large_canvas = page.of_art(); + let structure = match page { + Page::Program => ai::read_program(data, large_canvas, visible.is_some(), names)?, + _ if visible.is_some() => ai::read_over_page(data, large_canvas, names)?, + _ => ai::read_in(data, large_canvas, names)?, + }; build_from(structure, data, visible, page, outlined, trust, unit) } @@ -184,7 +198,7 @@ fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> O /// page, which draws nothing for them, the linked images' `placeholders`. A PDF part draws those /// images' pixels, which the placeholders don't match. fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Vec) -> Vec { - if matches!(page, Page::Art) { + if page.of_art() { restyled.extend(placeholders); } restyled @@ -295,7 +309,7 @@ pub(super) fn build_weighing( let mut frame = None; if let Some(visible) = visible { let art_box = match page { - Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, + Page::Art | Page::Program => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, }; let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; @@ -393,8 +407,9 @@ pub(super) fn build_weighing( // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); - let whole = matches!(page, Page::Art) && !artboard_page; - if whole { + let whole = page.of_art() && !artboard_page; + // A `.ai` keeps the artboard its editing copy gives, with the art outside it. + if whole && matches!(page, Page::Art) { // Its page is the file's own (its bounding box), as when it comes in as its page: art // reaching past the editing data's artboard stays on it (#1102). doc.artboards.truncate(1); @@ -1815,7 +1830,7 @@ fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option bool) -> usize { - if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { + if page.of_art() || visible.artboards.len() >= doc.artboards.len() { return 0; } let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index afd8f2568c..5154718a2f 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -13,12 +13,16 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with /// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the /// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one -/// layer; when that can't be used, the file is `visible` as it was. `names`: the encoding of names -/// that aren't UTF-8. -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, names: ai::NameEncoding) -> Imported { +/// layer; when that can't be used, the file is `visible` as it was. The program of a `.ai` +/// (`ai_file`) in the legacy format keeps the artboard its header gives ([`Page::Program`]). +/// `names`: the encoding of names that aren't UTF-8. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, ai_file: bool, names: ai::NameEncoding) -> Imported { + let program_page = if ai_file { Page::Program } else { Page::Art }; let Some(data) = editing_copy(ps) else { - let read = - illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0, names).ok()); + let read = illustrator + .then(|| one_layer(ps)) + .flatten() + .and_then(|p| build(&p, Some(&visible.document), program_page, false, trust, 1.0, names).ok()); return match read { Some((document, warnings)) => with_page_notes(document, warnings, &visible), None => visible, @@ -28,11 +32,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus // can't be used. // Private data kept apart from the program: the program's own header gives the author and // custom colors the private data's header may lack. - let read = - |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true, names) } else { ai::read_over_page(d, true, names) }; + let private = matches!(data, Ok(Cow::Owned(_))); + let read = |d: &[u8]| match (private, program_page) { + (true, _) => ai::read_beside(d, true, ps, true, names), + (false, Page::Program) => ai::read_program(d, true, true, names), + (false, _) => ai::read_over_page(d, true, names), + }; + // Private data is an EPS's editing copy, whatever the file is named. + let page = if private { Page::Art } else { program_page }; let (tree, result) = match data.as_deref() { Ok(d) => match read(d) { - Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), + Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), page, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, Err(why) => (vec![], Err(why.clone())), @@ -607,16 +617,23 @@ fn editing_copy(ps: &[u8]) -> Option, String>> { /// but whose program has no layers (Illustrator 3 had none; image editors still export their /// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program /// has no layers and isn't in the format: other PostScript isn't read as the format's operators. +/// The program of a `.ai` (`ai_file`) has the artboard its header gives ([`Page::Program`]). /// `names`: the encoding of names that aren't UTF-8. -pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option), String>> { +pub(in super::super) fn program_alone( + ps: &[u8], + illustrator: bool, + ai_file: bool, + names: ai::NameEncoding, +) -> Option), String>> { if ai::has_eps_data(ps) { return None; } + let page = if ai_file { Page::Program } else { Page::Art }; if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0, names)); + return Some(build(ps, None, page, false, false, 1.0, names)); } let program = illustrator.then(|| one_layer(ps)).flatten()?; - Some(build(&program, None, Page::Art, false, false, 1.0, names)) + Some(build(&program, None, page, false, false, 1.0, names)) } /// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 19d89ad33f..4cae36ac52 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::clipnest::MAX_NEST; use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; use vectorcraft_geom::Rect; -use crate::import::import; +use crate::import::{import, import_ai}; /// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the /// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`. @@ -329,6 +329,100 @@ fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); } +/// A `.ai` in the legacy format (version 8, `%AI5_FileFormat 4.0`) whose prolog is included: a +/// square on its 200-point artboard and one far to its right. Its bounding box is the box of that +/// art; its artboard is its art size around the template box's center (version 7 specification +/// §2.2). Its first line names the EPSF version, as the format's files may. +const LEGACY_AI_ART_OFF_ITS_ARTBOARD: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 320 30\n\ + %%HiResBoundingBox: 10 10 320 30\n%AI5_FileFormat 4.0\n%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n%AI5_ArtSize: 200 200\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def\n\ + /Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Art) Ln\n0 A\n0 0 0 1 k\n\ + 10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n300 10 m\n320 10 L\n320 30 L\n300 30 L\nf\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + +/// `bytes` opened as a `.ai`. +fn open_ai(bytes: &[u8]) -> crate::Imported { + import_ai(bytes, true, false, crate::NameEncoding::default()).unwrap() +} + +/// Such a `.ai` keeps the artboard its header gives, with the square outside it, when it comes in +/// through its layers. The same program opened as an EPS comes in on its page, its art's box +/// (#1102). +#[test] +fn a_legacy_ai_keeps_its_artboard_and_the_art_outside_it() { + let r = open_ai(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 200.0, 200.0)]); + let art: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // Art space has y up from the artboard's bottom: (10, 10) is 10 above it. + assert_eq!(art, [Rect::new(10.0, 170.0, 30.0, 190.0), Rect::new(300.0, 170.0, 320.0, 190.0)]); + let eps = import(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()).unwrap(); + let boards: Vec<_> = eps.document.artboards.iter().map(|a| a.rect.size()).collect(); + assert_eq!(boards, [kurbo::Size::new(310.0, 20.0)], "{:?}", eps.warnings); +} + +/// A version 3 `.ai` doesn't give its artboard's size (`%AI5_ArtSize` came with version 5): its +/// artboard is VectorCraft's own default, a US Letter page, around the template box's center, the +/// artboard's center (version 7 specification §2.2). Without a template box it is the art's box, +/// and so is the page of the same file opened as an EPS. +#[test] +fn a_version_3_ai_has_a_letter_artboard_around_its_template_boxs_center() { + let v3 = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 100 100 100 100\n"); + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + let art = d.layers[0].children().unwrap(); + assert_eq!(art[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + // The center of a template box with a size (a file with a template image). + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 50 50 150 150\n").as_bytes()); + assert_eq!(r.document.layers[0].children().unwrap()[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_ColorUsage: Color\n").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + let r = import(v3.as_bytes()).unwrap(); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + // A newer file (`%AI5_FileFormat`, version 5 on) without an art size keeps the art's box too. + let r = open_ai(v3.replace("%%EndComments", "%AI5_FileFormat 1.0\n%%EndComments").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); +} + +/// A version 3 `.ai` whose prolog is included and whose program has no layers: its page draws, and +/// its art comes in on one layer around a US Letter artboard, the square far to the right outside +/// it. +#[test] +fn a_version_3_ai_that_draws_keeps_its_letter_artboard_and_the_art_outside_it() { + let v3 = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 720 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n\ + 700 10 m\n720 10 L\n720 30 L\n700 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + assert_eq!(r.document.layers.len(), 1); + let art: Vec<_> = r.document.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + assert_eq!(art, [Rect::new(216.0, 466.0, 236.0, 486.0), Rect::new(906.0, 466.0, 926.0, 486.0)]); + let eps = import(v3.as_bytes()).unwrap(); + assert_eq!(eps.document.artboards[0].rect, Rect::new(0.0, 0.0, 710.0, 20.0), "{:?}", eps.warnings); +} + +/// A `.ai` another app wrote (#1027) that gives a template box but no art size opens on a US Letter +/// artboard around the box's center, from -18 to 594 across and -12 to 780 up in art space (as an +/// EPS it opens on its bounding box, see above). +#[test] +fn a_postscript_ai_with_a_template_box_and_no_art_size_opens_on_a_letter_artboard() { + let r = open_ai(NAMED_PROLOG.as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), "{:?}", r.warnings); + let square = &r.document.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(28.0, 690.0, 108.0, 770.0))); +} + /// A placed EPS in the legacy format (version 7 specification §16, no `%AI5_BeginPlace`), its file /// included whole: the layers come in, the placed file as the art the page draws in its box. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 4c5b8dde8a..327dc22f6f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -4791,6 +4791,9 @@ fn an_eps_page_of_its_art_stays_its_page_through_the_layers() { assert_eq!(boards.len(), 1); assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}"); assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}"); + // Its private data is an EPS's editing copy, whatever the file is named. + let named_ai = crate::import::import_ai(&file, true, false, crate::NameEncoding::default()).unwrap(); + assert_eq!(named_ai.document.artboards.iter().map(|a| a.rect).collect::>(), boards, "{:?}", named_ai.warnings); } #[test] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index ada3171e0a..e36422b759 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -49,7 +49,7 @@ mod tiff; pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{ AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, - family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, + family_style, import, import_ai, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, }; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c41e6fe86f..b7cc85c1b9 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -955,7 +955,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph, or for the series picked in it with Group Selection (dialog `graphType`, fields the options graph.setType {} returns under their names, with legend, pieLegend, piePosition, pieSort, firstRowInFront, firstColumnInFront and filledLines held for every type; overrideValues and rightOverrideValues, Override Calculated Values of the left or only value axis and of the right one: while one is on OK sends axisMin, axisMax and ticks, or rightAxisMin, rightAxisMax and rightTicks, filled with the calculated values where the graph has none, and one turned off sends null, null and 0; __page: options|valueAxis|rightAxis|categoryAxis, the page shown, options when the type picked has no such page; OK runs graph.setType, and with nothing changed closes without a step of history; an overridden value axis whose maximum is not more than its minimum is an error and the dialog stays open; an error without a selected graph), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 5d700f9c5a..ef3934c2d6 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -758,7 +758,15 @@ struct Ctx { /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. - paths: Rc>>>>, + paths: Rc>>>>, +} + +/// The path a `` follows ([`Ctx::text_path`]). +struct FollowedPath { + path: BezPath, + /// The `pathLength` of the element the `` points at, 0 or greater; `None` when the + /// `` has its own `path` or the element has no such value. + author_length: Option, } #[cfg(test)] @@ -1172,7 +1180,7 @@ impl Ctx { /// The path a `` follows, in the text's user space. It is read from the file once /// ([`Self::paths`]). - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { if let Some(known) = self.paths.borrow().get(&tp.id()) { return known.clone(); } @@ -1184,13 +1192,14 @@ impl Ctx { /// How many elements the path of `` `tp` has, when a text built so far follows it /// ([`Self::text_path`]); 0 for any other node. fn path_elements(&self, tp: XNode) -> usize { - self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.path.elements().len()) } /// Find and parse the path `` `tp` follows. - fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { #[cfg(test)] TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); + let mut author_length = None; let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -1202,6 +1211,8 @@ impl Ctx { }; let mut bp = BezPath::from_svg(&d).ok()?; bp.apply_affine(parse_transform(el.attribute("transform"))); + author_length = + el.attribute("pathLength").and_then(|v| v.trim().parse::().ok()).filter(|l| l.is_finite() && *l >= 0.0).map(f64::abs); bp } }; @@ -1209,7 +1220,7 @@ impl Ctx { bp = bp.reverse_subpaths(); } let usable = bp.segments().next().is_some(); - usable.then_some(bp) + usable.then_some(FollowedPath { path: bp, author_length }) } /// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a @@ -1458,7 +1469,8 @@ impl Ctx { let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; let mut para_styles: Vec = vec![]; - if let Some((tp, bp)) = path { + if let Some((tp, followed)) = path { + let bp = &followed.path; // Along the path: dx is extra advance, dy shifts off the path. let mut off = 0.0; for i in 0..n { @@ -1470,13 +1482,34 @@ impl Ctx { } let len: f64 = bp.segments().map(|s| s.arclen(1e-4)).sum(); let em = self.font_size(tp); - let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; + // `startOffset` as a share of the path's length. A percentage is of the path's computed + // length. Any other length is in the units of the referenced element's `pathLength` + // when it has one, and a `pathLength` of 0 puts any offset other than 0 infinitely far + // along (SVG 2 › Paths › The pathLength attribute). An offset infinitely far along + // starts as far past the path's end, or before its start, as type may go. + let share = tp + .attribute("startOffset") + .and_then(|v| { + let o = self.length(v, em, len)?; + let share = match followed.author_length { + Some(p) if !v.trim().ends_with('%') => { + if o == 0.0 { + 0.0 + } else { + o / p + } + } + _ => o / len, + }; + Some(if share.is_infinite() { vectorcraft_doc::PATH_LEAD_MAX.copysign(share) } else { share }) + }) + .unwrap_or(0.0); let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; // On a path of one closed subpath, text that starts before the path's start or past its // end wraps around the path. On an open path text may start before the path's start, // and its characters before the start are not drawn; text that starts past the end // starts at the end. Other paths start it at the path's start or end. - let path = PathData::from_bezpath(&bp); + let path = PathData::from_bezpath(bp); let wraps = path.subpaths.len() == 1 && path.is_closed(); let open = !path.is_closed(); let wrap = |s: f64| { @@ -1484,7 +1517,7 @@ impl Ctx { let s = s.rem_euclid(1.0); if s < 1.0 { s } else { 0.0 } }; - let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { + let start = Some(share + (dx[0] - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { if wraps { wrap(s) } else if open { diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs index 1508997dcc..a6bcf07265 100644 --- a/crates/svg/src/import/text/tests.rs +++ b/crates/svg/src/import/text/tests.rs @@ -21,4 +21,9 @@ fn uses_of_a_text_on_a_path_read_the_path_once() { // A path with no usable data is not read again either. let (texts, reads, _) = prepared(&format!(r##"Path{uses}"##)); assert_eq!((texts, reads), (51, 1)); + // The path's pathLength is read with it. + let (texts, reads, _) = prepared(&format!( + r##"Path{uses}"## + )); + assert_eq!((texts, reads), (51, 1)); } diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 72b476eb24..fa91f8d162 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -299,6 +299,38 @@ fn text_path_becomes_type_on_a_path() { assert_eq!(t[2].para.justify, Justify::Left); } +#[test] +fn text_path_start_offset_is_scaled_by_the_path_length() { + // A path 400 long whose pathLength is 100: a startOffset of 50 lies 200 along it. A + // percentage stays a share of the computed length. Huge values of both still give their + // ratio. With a pathLength of 0, an offset of 0 stays at the start and any other offset lies + // past the end, as does an offset too large to scale on a short path. + let d = open( + r##" + + + + Half + Quarter + Huge + Zero + Fifty + Past"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 6); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + assert!(close(start_of(t[0]), 0.5, 1e-9), "{}", start_of(t[0])); + assert!(close(start_of(t[1]), 0.25, 1e-9), "{}", start_of(t[1])); + assert!(close(start_of(t[2]), 0.5, 1e-9), "{}", start_of(t[2])); + assert_eq!(start_of(t[3]), 0.0); + assert_eq!(start_of(t[4]), 1.0); + assert_eq!(start_of(t[5]), 1.0); +} + #[test] fn text_on_a_closed_path_wraps_past_the_path_start() { // A closed path 400 long: text anchored before its start, or offset past its end, wraps diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 235715a0b6..bdb1ca2e7b 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,7 +1,7 @@ use super::cache::RegionCache; use super::lookup::{ - connected_edges, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, visible_edge_near, - visible_face_at, + connected_edges, edges, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, + visible_edge_near, visible_face_at, }; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ @@ -711,8 +711,14 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// with Shift held at the press, each of them leaves the selection if it is selected and joins it /// otherwise. A double-click on a face selects it and the faces of its group that are not parted /// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end -/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them -/// leaves the selection if it is selected and joins it otherwise. +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). A triple-click on a face +/// selects every face of its group with that face's fill; on an edge, every edge of its group with +/// that edge's stroke paint ([`LivePaintSelectionTool::same_paint_at`]). With Shift held, each part a +/// double-click reaches leaves the selection if it is selected and joins it otherwise, and after a +/// Shift-triple-click each part with the clicked part's paint is toggled from the selection there +/// was before the three clicks. Clicks of the three made without Shift select as they do alone, so +/// when Shift is first held at a later click, the parts are toggled from the selection there was +/// before that click. /// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer @@ -733,6 +739,33 @@ pub struct LivePaintSelectionTool { width: f64, /// The drag in progress, from the press to the release. drag: Option, + /// The part each of the last two clicks toggled with Shift held, the latest last (None for a + /// click that toggled nothing). A click or marquee without Shift clears both. + recent: [Option; 2], + /// The parts the last double-click, the two clicks before it and the Shift-clicks after it + /// toggled, each listed once when toggled an odd number of times. A Shift-triple-click toggles + /// them back as it toggles the parts with the clicked part's paint, so the selection ends as it + /// was before the clicks with those parts toggled. + toggled: Vec, +} + +/// `a` (which lists each id once) with each id of `b` toggled in turn: an id `b` lists an odd number +/// of times is removed when `a` holds it and added at the end otherwise, in `b`'s order. +fn toggle_ids(a: &mut Vec, b: &[NodeId]) { + use std::collections::HashSet; + let mut odd = HashSet::new(); + for id in b { + if !odd.insert(*id) { + odd.remove(id); + } + } + let held: HashSet = a.iter().copied().collect(); + a.retain(|x| !odd.contains(x)); + for id in b { + if !held.contains(id) && odd.remove(id) { + a.push(*id); + } + } } /// A press and drag of the Live Paint Selection tool. @@ -770,6 +803,8 @@ impl Default for LivePaintSelectionTool { color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH, drag: None, + recent: [None; 2], + toggled: vec![], } } } @@ -812,6 +847,25 @@ impl LivePaintSelectionTool { let Some(group) = cx.doc.node(gid) else { return vec![] }; if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } } + + /// What a triple-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and every other face of its group that is + /// not hidden and has its fill; on the edge a click picks, that edge and every other edge of its + /// group that is not hidden and draws with the stroke paint it draws with ([`lp_stroke_paint`]). + /// The part under the pointer comes first. Empty where a click selects no face or edge. + fn same_paint_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + let others: Vec = if edge { + let like = lp_stroke_paint(part); + edges(group).into_iter().filter(|e| e.visible && e.id != part.id && lp_stroke_paint(e) == like).map(|e| e.id).collect() + } else { + let like = part.appearance.fill_paint(); + faces(group).into_iter().filter(|f| f.visible && f.id != part.id && f.appearance.fill_paint() == like).map(|f| f.id).collect() + }; + std::iter::once(part.id).chain(others).collect() + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -918,6 +972,17 @@ impl Tool for LivePaintSelectionTool { let d = LpDrag { cur: p, ..d }; let Some(r) = d.marquee(cx) else { // A click selects what the press picked. + // A click without Shift replaces the selection, so what earlier clicks toggled + // no longer counts. + if d.shift { + self.recent = [self.recent[1], d.hit]; + if let Some(id) = d.hit { + toggle_ids(&mut self.toggled, &[id]); + } + } else { + self.recent = [None; 2]; + self.toggled.clear(); + } return match (d.hit, d.shift) { (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], @@ -925,6 +990,8 @@ impl Tool for LivePaintSelectionTool { (None, false) => vec![Action::Exec("select.none".into(), json!({}))], }; }; + self.recent = [None; 2]; + self.toggled.clear(); let ids = lp_parts_in(cx, r, self.fills, self.strokes); match (ids.is_empty(), d.shift) { (true, true) => vec![], @@ -937,15 +1004,36 @@ impl Tool for LivePaintSelectionTool { // The two clicks before it already selected the part under the pointer (or, with // Shift, toggled it twice); the double-click selects the part with what it reaches. let ids = self.reach_at(cx, p, ev.mods.alt); + self.toggled.clear(); + if ev.mods.shift { + let clicks: Vec = self.recent.iter().flatten().copied().collect(); + toggle_ids(&mut self.toggled, &clicks); + toggle_ids(&mut self.toggled, &ids); + } match (ids.is_empty(), ev.mods.shift) { (true, _) => vec![], (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - // The tool takes no action on a triple-click; its third press and release select as a - // click does. - PointerKind::TripleClick => vec![], + PointerKind::TripleClick => { + // The clicks before it already selected the part under the pointer; the + // triple-click selects the part with every part of its group that has its paint. + // With Shift held, the double-click and the third click toggled parts too, and the + // triple-click toggles those back. + let ids = self.same_paint_at(cx, p, ev.mods.alt); + let toggled = std::mem::take(&mut self.toggled); + self.recent = [None; 2]; + if ids.is_empty() { + return vec![]; + } + if !ev.mods.shift { + return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]; + } + let mut flip = ids; + toggle_ids(&mut flip, &toggled); + if flip.is_empty() { vec![] } else { vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&flip) }))] } + } } } @@ -963,6 +1051,8 @@ impl Tool for LivePaintSelectionTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.drag = None; + self.recent = [None; 2]; + self.toggled.clear(); vec![] } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 66db1e550c..75e15da82d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -2573,20 +2573,167 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() { assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); } +/// A triple-click on a face selects every face of its group with that face's fill, joined to it or +/// not; on an edge (Alt), every edge of its group that draws with that edge's stroke paint. Shift +/// toggles them. Hidden parts, other groups and parts with another paint are left out. #[test] -fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() { +fn live_paint_selection_triple_click_selects_every_part_of_the_same_paint() { let mut d = Document::new(500.0, 500.0); - let (_, [a, ..], _) = live_strip(&mut d); + let (_, [a, b, c], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let (other, [_, other_top, ..]) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let other_face = faces(d.node(other).unwrap())[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; the other group's face is red and its top edge black, too. + for (id, fill) in [(a, &red), (b, &blue), (c, &red), (other_face, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } + d.node_mut(other_top).unwrap().appearance.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + let s = Selection::default(); + let p = paint(); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let tri = |d: &Document, t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| { + let cx = cx(d, &s, &p); + t.pointer(&cx, &ev(PointerKind::TripleClick, x, y).with_mods(m)) + }; + let mut t = LivePaintSelectionTool::default(); + // A face: every face of its group with its fill, even across the stroked edge BC; the clicked face first. + assert_eq!(tri(&d, &mut t, 125.0, 25.0, Mods::default()), set(&[c, a])); + assert_eq!(tri(&d, &mut t, 75.0, 25.0, Mods::default()), set(&[b])); + // An edge: every edge of its group that draws with its stroke paint, joined to it or not. + assert_eq!(tri(&d, &mut t, 25.0, 1.0, alt), set(&[top_a, top_b, bottom])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, alt), set(&[top_c, bc])); + assert_eq!(tri(&d, &mut t, 50.0, 25.0, alt), set(&[ab])); + // With Shift held, the parts leave the selection or join it. + assert_eq!(tri(&d, &mut t, 25.0, 25.0, shift), toggle(&[a, c])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_c, bc])); + // Away from the group, a triple-click adds nothing to what its clicks did. + assert!(tri(&d, &mut t, 400.0, 400.0, Mods::default()).is_empty()); + // A hidden face of the same fill is left out. + d.node_mut(c).unwrap().visible = false; + assert_eq!(tri(&d, &mut t, 25.0, 25.0, Mods::default()), set(&[a])); + // On the other group, a triple-click selects that group's parts alone. + assert_eq!(tri(&d, &mut t, 250.0, 50.0, Mods::default()), set(&[other_face])); + assert_eq!(tri(&d, &mut t, 250.0, 1.0, alt), set(&[other_top])); + // Select Fills off: the triple-click picks the edge nearest the pointer; both off: nothing. + t.set_option("selectFills", &json!(false)); + assert_eq!(tri(&d, &mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b, bottom])); + t.set_option("selectStrokes", &json!(false)); + assert!(tri(&d, &mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(tri(&d, &mut t, 25.0, 1.0, alt).is_empty()); +} + +/// The whole event series of a triple-click (three presses and releases, the double-click after +/// the second release and the triple-click after the third), applied to a selection: without +/// Shift the selection ends as the parts with the clicked part's paint, and with Shift as the +/// selection from before the clicks with those parts toggled, whatever the double-click reached. +#[test] +fn live_paint_selection_triple_click_series_ends_with_the_same_paint_parts() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; a double-click on A also reaches B across the unstroked edge AB. + for (id, fill) in [(a, &red), (b, &blue), (c, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); + let apply = |sel: &mut Vec, acts: Vec| { + for act in acts { + let Action::Exec(cmd, args) = act else { panic!("{act:?}") }; + let ids: Vec = match (args.get("ids"), args.get("id")) { + (Some(v), _) => v.as_array().unwrap().iter().map(|i| i.as_u64().unwrap()).collect(), + (None, Some(i)) => vec![i.as_u64().unwrap()], + (None, None) => vec![], + }; + match cmd.as_str() { + "select.set" => *sel = ids, + "select.none" => sel.clear(), + "select.toggle" => { + for i in ids { + match sel.iter().position(|x| *x == i) { + Some(k) => { + sel.remove(k); + } + None => sel.push(i), + } + } + } + other => panic!("{other}"), + } + } + }; + // The first click at `first`, every later event at (`x`, `y`). + let series = |t: &mut LivePaintSelectionTool, sel: &mut Vec, first: (f64, f64), x: f64, y: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up] { + apply(sel, t.pointer(&cx, &ev(kind, first.0, first.1).with_mods(m))); + } + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick, PointerKind::Down, PointerKind::Up, PointerKind::TripleClick] { + apply(sel, t.pointer(&cx, &ev(kind, x, y).with_mods(m))); + } + }; + let sorted = |ids: &[NodeId]| { + let mut v: Vec = ids.iter().map(|i| i.0).collect(); + v.sort_unstable(); + v + }; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; let mut t = LivePaintSelectionTool::default(); - // The third click's press and release select the face under the pointer, as a click does. - assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]); - for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] { - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty()); + for (start, first, x, y, m, want) in [ + (vec![], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![top_c.0], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, c])), + (vec![top_c.0, c.0], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, top_c])), + (vec![], (25.0, 1.0), 25.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }, sorted(&[top_a, top_b, bottom])), + (vec![bc.0], (125.0, 1.0), 125.0, 1.0, alt, sorted(&[top_c, bc])), + // The first click on A and the rest on B, beside it: B alone has B's fill. + (vec![], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b])), + (vec![c.0], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b, c])), + ] { + let mut sel = start.clone(); + series(&mut t, &mut sel, first, x, y, m); + sel.sort_unstable(); + assert_eq!(sel, want, "from {start:?} at {first:?} then ({x}, {y}) with {m:?}"); } + // Shift first held at the third click: the two clicks and the double-click select A and B, and + // the parts with A's fill are toggled from that selection. + let mut sel = vec![top_c.0]; + for (kind, m) in [ + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::DoubleClick, Mods::default()), + (PointerKind::Down, shift), + (PointerKind::Up, shift), + (PointerKind::TripleClick, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, 25.0, 25.0).with_mods(m))); + } + sel.sort_unstable(); + assert_eq!(sel, sorted(&[b, c])); + // A Shift-click on C, then a click on A without Shift, which selects A alone, and Shift held + // from the double-click on: the parts with A's fill are toggled from that selection. + let mut sel = vec![]; + for (kind, x, m) in [ + (PointerKind::Down, 125.0, shift), + (PointerKind::Up, 125.0, shift), + (PointerKind::Down, 25.0, Mods::default()), + (PointerKind::Up, 25.0, Mods::default()), + (PointerKind::DoubleClick, 25.0, shift), + (PointerKind::Down, 25.0, shift), + (PointerKind::Up, 25.0, shift), + (PointerKind::TripleClick, 25.0, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, x, 25.0).with_mods(m))); + } + assert_eq!(sel, vec![c.0]); } #[test] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 69230dc050..d6620894ed 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -188,6 +188,8 @@ fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool { pub struct DirectSelectionTool { group: bool, state: State, + /// How far the move's last preview carried the anchors or the objects ([`Tool::readout`]). + moved: Option, /// The spine point last clicked (blend, anchor): its handles show and can be dragged. spine: Option<(NodeId, usize)>, /// Other points of that spine picked with it (Shift-click adds or takes one away); dragging @@ -251,6 +253,7 @@ impl DirectSelectionTool { Self { group, state: State::Idle, + moved: None, spine: None, spine_more: vec![], mesh: MeshEdit::default(), @@ -733,6 +736,10 @@ impl Tool for DirectSelectionTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) || self.guide.busy() } + fn readout(&self, _cx: &ToolContext) -> Option { + let moving = matches!(self.state, State::MoveAnchors { began: true, .. } | State::MoveObject { began: true, .. }); + self.moved.filter(|_| moving).map(crate::Readout::drag) + } fn options(&self) -> Value { self.mesh.options() } @@ -1486,6 +1493,7 @@ impl DirectSelectionTool { (d, self.guides) = (q - grab, guides); } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); out } @@ -1538,6 +1546,7 @@ impl DirectSelectionTool { (d, self.guides) = (q - held, guides); } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); return out; } @@ -1553,6 +1562,7 @@ impl DirectSelectionTool { (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview( "object.transform".into(), json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 72cb02b6db..e6a15b5242 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -378,9 +378,11 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } - // A moved plane stays in front of the viewer. - let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9); - if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) { + // A moved plane stays in front of the viewer, both where Automatic Plane Positioning holds + // it and in its own place, which it goes back to. + let behind = |p: Plane, d: f64| self.axes(p).is_some_and(|[.., n]| 1.0 + d * self.axis_col(n)[2] <= 1e-9); + let bad = |p: Plane, d: f64| !d.is_finite() || d.abs() > MAX_DEPTH || behind(p, d); + if Plane::ALL.iter().any(|p| bad(*p, self.offset(*p)) || bad(*p, self.placed_offset(*p))) { return Err("a plane is moved too far (behind the viewer)".into()); } self.validate_definition() diff --git a/crates/tools/src/distort/perspective/infer.rs b/crates/tools/src/distort/perspective/infer.rs index 26faa97747..e5f9fd1a8d 100644 --- a/crates/tools/src/distort/perspective/infer.rs +++ b/crates/tools/src/distort/perspective/infer.rs @@ -131,17 +131,32 @@ impl PerspectiveGrid { for plane in self.shown_planes() { let depth = self.offset(plane); let Some(h) = self.homography_at(plane, depth) else { continue }; - let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue }; + let Some(hi) = h.inverse() else { continue }; let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue }; - // The drawn lines nearest the pointer along each axis, and their neighbors: the - // projection skews and shrinks the cells, so the intersection nearest on the page can be - // one over from the nearest in the plane. - let near = |v: f64, step: f64, last: usize| { - let i = (v / step).round().clamp(0.0, last as f64) as i64; - (i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step) + // On each drawn line, the intersections either side of the point of the line nearest + // the pointer on the page. The projection keeps the order of points along a line, so + // the nearest intersection on that line is one of those two, however skewed the cells. + let mut candidates = Vec::with_capacity(2 * (nu + nv + 2)); + let mut along = |line: &dyn Fn(f64) -> Point, step: f64, last: usize, coord: fn(Point) -> f64| { + let (Some(a), Some(b)) = (h.apply(line(0.0)), h.apply(line(last as f64 * step))) else { return }; + let ab = b - a; + let len2 = ab.x * ab.x + ab.y * ab.y; + let t = if len2 > 0.0 { (((p - a).x * ab.x + (p - a).y * ab.y) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let Some(at) = hi.apply(a + ab * t).map(coord).filter(|v| v.is_finite()) else { return }; + let k = (at / step).floor().clamp(0.0, last as f64); + for k in [k, (k + 1.0).min(last as f64)] { + candidates.push(line(k * step)); + } }; - for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) { - let q = Point::new(u, v); + for i in 0..=nu { + let u = i as f64 * su; + along(&|v| Point::new(u, v), sv, nv, |q| q.y); + } + for i in 0..=nv { + let v = i as f64 * sv; + along(&|u| Point::new(u, v), su, nu, |q| q.x); + } + for q in candidates { let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue }; if d <= tol && best.is_none_or(|b| d < b.0) @@ -268,6 +283,33 @@ mod tests { assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none()); } + #[test] + fn a_held_plane_still_checks_its_own_place() { + // Held at x = 0, the left wall's own place must stay in front of the viewer too: it goes + // back there once positioning ends. + let g = PerspectiveGrid { plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + assert!(g.merged(&json!({"leftOffset": -800})).is_err()); + assert!(PerspectiveGrid { inferred: None, ..g.clone() }.merged(&json!({"leftOffset": -800})).is_err()); + let moved = g.merged(&json!({"leftOffset": 40})).unwrap(); + assert_eq!((moved.placed_offset(Plane::Left), moved.offset(Plane::Left), moved.inferred), (40.0, 0.0, Some([0.0; 3]))); + } + + #[test] + fn the_nearest_intersection_on_the_page_is_found_on_a_skewed_plane() { + // The left vanishing point next to the origin skews the left wall's cells hard: the + // intersection nearest on the page lies many cells from the pointer's inverse. + let g = PerspectiveGrid { vp_left: 399.0, plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + let d = Document::new(800.0, 600.0); + let gridlines = PlaneTargets { anchors: false, gridlines: true }; + let p = Point::new(400.0, 219.761194); + let want = Point::new(10.0 * g.cell, 10.0 * g.cell); + assert!((g.to_page(Plane::Left, want).unwrap() - p).hypot() < 0.5); + let (_, at) = g.infer_at(&d, p, 6.0, gridlines).unwrap(); + for (got, want) in at.iter().zip([0.0, want.y, want.x]) { + assert!((got - want).abs() < 1e-6, "{at:?}"); + } + } + #[test] fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() { let g = PerspectiveGrid { plane: Plane::Right, ..grid() }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9edf18e84d..7a5650a25b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,10 +57,12 @@ pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; mod context; mod cursor; mod input; +mod readout; pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; +pub use readout::Readout; /// Visual feedback drawn by the UI in screen space (coordinates here are document space). #[derive(Clone, Debug, PartialEq)] @@ -265,6 +267,12 @@ pub trait Tool: Send { fn wants_rehover(&self) -> bool { false } + /// What the Info panel shows for the tool as it is now: the drag in progress (a move of the + /// selection, a gradient vector, a Scale, Rotate, Reflect or Shear tool drag), or the + /// Paintbrush's brush. None for a tool with nothing of its own to show. + fn readout(&self, _cx: &ToolContext) -> Option { + None + } /// Called when the user switches away (finish pending work). fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] diff --git a/crates/tools/src/readout.rs b/crates/tools/src/readout.rs new file mode 100644 index 0000000000..36fa7d6e85 --- /dev/null +++ b/crates/tools/src/readout.rs @@ -0,0 +1,76 @@ +//! What the tool in use gives the Info panel to show ([`crate::Tool::readout`]). + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Vec2}; + +/// The Info panel's values for the tool in use. Lengths and points are in document units, and +/// angles are in degrees as the matching command takes them: counter-clockwise for +/// `object.rotate`'s `angle` and `object.reflect`'s `axis`, and `object.shear`'s `angle`. +#[derive(Clone, Debug, PartialEq)] +pub enum Readout { + /// A drag that moves the selection or draws a gradient vector: how far it has gone in x and y. + Drag { dx: f64, dy: f64 }, + /// A Scale tool drag: the horizontal and vertical scale (1 leaves the size as it is). + Scale { sx: f64, sy: f64 }, + /// A Rotate tool drag: where the center of the selection's bounding box is, and the angle + /// turned. + Rotate { center: Point, angle: f64 }, + /// A Reflect tool drag: where the center of the selection's bounding box is, and the angle of + /// the axis. + Reflect { center: Point, angle: f64 }, + /// A Shear tool drag: where the center of the selection's bounding box is, the angle of the + /// shear axis (0 horizontal, 90 vertical) and the shear angle. + Shear { center: Point, axis: f64, angle: f64 }, + /// The Paintbrush: the brush it paints with, if any. + Brush { name: Option }, +} + +impl Readout { + /// The readout of a drag by `d`. + pub fn drag(d: Vec2) -> Self { + Readout::Drag { dx: d.x, dy: d.y } + } + + /// The length of a [`Readout::Drag`] and its angle, as the Measure tool gives them. + pub fn distance_angle(&self) -> Option<(f64, f64)> { + match *self { + Readout::Drag { dx, dy } => { + let (dist, angle, ..) = crate::xform::measure(Point::ORIGIN, Point::new(dx, dy)); + Some((dist, angle)) + } + _ => None, + } + } + + /// The readout as `info.readout` reports it. + pub fn to_json(&self) -> Value { + let at = |p: Point| json!([p.x, p.y]); + match self { + Readout::Drag { dx, dy } => { + let (distance, angle) = self.distance_angle().unwrap_or_default(); + json!({ "kind": "drag", "dx": dx, "dy": dy, "distance": distance, "angle": angle }) + } + Readout::Scale { sx, sy } => json!({ "kind": "scale", "width": sx * 100.0, "height": sy * 100.0 }), + Readout::Rotate { center, angle } => json!({ "kind": "rotate", "center": at(*center), "angle": angle }), + Readout::Reflect { center, angle } => json!({ "kind": "reflect", "center": at(*center), "angle": angle }), + Readout::Shear { center, axis, angle } => json!({ "kind": "shear", "center": at(*center), "axis": axis, "angle": angle }), + Readout::Brush { name } => json!({ "kind": "brush", "brush": name }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drag_gives_its_length_and_its_angle_up_the_page() { + let r = Readout::drag(Vec2::new(30.0, -40.0)); + let (d, a) = r.distance_angle().unwrap(); + assert!((d - 50.0).abs() < 1e-9 && (a - 53.13010235415598).abs() < 1e-9, "{d} {a}"); + assert_eq!(r.to_json()["dx"], 30.0); + assert_eq!(Readout::Scale { sx: 1.5, sy: 0.5 }.to_json(), json!({"kind": "scale", "width": 150.0, "height": 50.0})); + assert_eq!(Readout::Scale { sx: 1.0, sy: 1.0 }.distance_angle(), None); + assert_eq!(Readout::Brush { name: None }.to_json(), json!({"kind": "brush", "brush": null})); + } +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f63be1193e..8a755395dc 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -172,6 +172,8 @@ pub struct SelectionTool { /// The params of the move's last preview: in Shaper's Construction mode the move ends with /// them and `releaseOutside` ([`construction`]). last_move: Option, + /// How far the move's last preview carried the selection ([`Tool::readout`]). + moved: Option, /// Held state used for the most recent bounding-box preview. last_backquote: bool, /// Pointer position used for the most recent bounding-box preview. @@ -898,6 +900,10 @@ impl Tool for SelectionTool { matches!(self.state, State::Moving { began: true, .. }) } + fn readout(&self, _cx: &ToolContext) -> Option { + self.moved.filter(|_| self.moving()).map(crate::Readout::drag) + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, @@ -1753,6 +1759,7 @@ impl SelectionTool { params["patterns"] = json!(true); } self.last_move = Some(params.clone()); + self.moved = Some(d); out.push(Action::Preview("object.transform".into(), params)); out } diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs index 580e61d4fd..ba00ec4125 100644 --- a/crates/tools/src/symbolism.rs +++ b/crates/tools/src/symbolism.rs @@ -299,6 +299,9 @@ impl Tool for BrushPaintTool { fn busy(&self) -> bool { self.inner.busy() } + fn readout(&self, cx: &ToolContext) -> Option { + Some(crate::Readout::Brush { name: self.brush_name(cx) }) + } fn deactivate(&mut self, cx: &ToolContext) -> Vec { let acts = self.inner.deactivate(cx); self.map(cx, acts) diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 5a3565f3a9..117df38463 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -545,6 +545,12 @@ impl Tool for GradientTool { self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began) } + /// The vector a drag on the art has drawn so far. + fn readout(&self, _cx: &ToolContext) -> Option { + let (start, end) = self.gesture.as_ref().filter(|g| g.began)?.vector?; + Some(crate::Readout::drag(end - start)) + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { // A freeform gradient: its points instead of the bar. match (ev.kind, &mut self.free) { diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 1cbf1cac53..16a1b93a8a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -20,7 +20,7 @@ use super::target_overlays; use crate::bbox::rotate_for_drag; use crate::guides::{PointSnap, Targets, snap_pick}; use crate::select::{matrix_json, selection_bounds, selection_box}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Readout, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TransformKind { @@ -98,6 +98,8 @@ pub struct TransformTool { origin: Option<(Point, Vec)>, drag: Option, measure: Option<(Point, String)>, + /// What the Info panel shows for the drag in progress. + readout: Option, /// Snap feedback while hovering ("anchor" / "center" label), and for the dragged pointer. hover: Vec, /// What the dragged pointer snaps to, gathered when the drag begins. @@ -126,7 +128,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine { impl TransformTool { pub fn new(kind: TransformKind) -> Self { - Self { kind, origin: None, drag: None, measure: None, hover: vec![], snap: None } + Self { kind, origin: None, drag: None, measure: None, readout: None, hover: vec![], snap: None } } /// The effective reference point: the custom one if it belongs to the current selection, @@ -154,12 +156,44 @@ impl TransformTool { /// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the /// line the pointer draws on the page. pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + let (m, a, b) = self.solve(angle, o, start, p, shift, bounds); + (m, self.label(a, b)) + } + + /// The canvas label for the two values [`Self::solve`] gives. + fn label(&self, a: f64, b: f64) -> String { + match self.kind { + TransformKind::Rotate => format!("{:.1}°", -a), + TransformKind::Reflect => format!("{a:.1}°"), + TransformKind::Scale => format!("W: {:.1}%\nH: {:.1}%", a * 100.0, b * 100.0), + TransformKind::Shear => format!("{:.1}°", if a == 0.0 { b } else { -b }), + } + } + + /// What the Info panel shows for the transform `m` of a selection with `bounds`, with the two + /// values [`Self::solve`] gives. + fn readout_of(&self, m: Affine, a: f64, b: f64, bounds: Rect) -> Readout { + let center = m * bounds.center(); + match self.kind { + TransformKind::Rotate => Readout::Rotate { center, angle: a }, + TransformKind::Reflect => Readout::Reflect { center, angle: a }, + TransformKind::Scale => Readout::Scale { sx: a, sy: b }, + TransformKind::Shear => Readout::Shear { center, axis: a, angle: b }, + } + } + + /// [`TransformTool::matrix_in`]'s transform, with the two values of its readout: the angle + /// turned, in degrees counter-clockwise as `object.rotate` takes it (Rotate), the angle of the + /// axis in degrees counter-clockwise (Reflect), the scale along the box's width and height + /// (Scale), or the angle of the shear axis (0 horizontal, 90 vertical) and the shear angle as + /// `object.shear` takes it (Shear). + fn solve(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, f64, f64) { let frame = OrientedBox { angle, rect: Rect::ZERO }; let local = |v: Vec2| frame.to_doc().inverse() * v.to_point(); match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); - (a, format!("{:.1}°", -deg)) + (a, deg, 0.0) } TransformKind::Reflect => { let mut v = p - o; @@ -169,7 +203,7 @@ impl TransformTool { v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2(); } let theta = v.y.atan2(v.x); - (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) + (reflect_matrix(o, theta), vectorcraft_geom::normalize_deg(-theta.to_degrees()), 0.0) } TransformKind::Scale => { let d0 = local(start - o).to_vec2(); @@ -184,7 +218,7 @@ impl TransformTool { } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); - (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), sx, sy) } TransformKind::Shear => { // The shear axis follows the dominant drag direction. Shift keeps the drag on that @@ -223,7 +257,7 @@ impl TransformTool { }; } } - (m, format!("{:.1}°", (-k.atan()).to_degrees())) + if horizontal { (m, 0.0, (-k.atan()).to_degrees()) } else { (m, 90.0, k.atan().to_degrees()) } } } } @@ -231,6 +265,7 @@ impl TransformTool { /// Ends the drag, its snapping and readouts with it. fn end_drag(&mut self) -> Option { self.measure = None; + self.readout = None; self.snap = None; self.hover.clear(); self.drag.take() @@ -250,6 +285,10 @@ impl Tool for TransformTool { self.drag.is_some_and(|d| d.began) } + fn readout(&self, _cx: &ToolContext) -> Option { + self.readout.clone() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; match ev.kind { @@ -294,10 +333,11 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); + let (m, a, b) = self.solve(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); - self.measure = Some((p, text)); + self.measure = Some((p, self.label(a, b))); + self.readout = Some(self.readout_of(m, a, b, d.bounds)); out.push(preview(m, ev.mods.alt, ev.backquote)); out } diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 9a7031256a..a54fdfacc7 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -13,7 +13,7 @@ pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; pub use palette::FULL_TONE_MAX; -use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_colors, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library}; +use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_count, flat_palette, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library}; #[cfg(test)] pub(crate) use rare::kept_rare; use rare::{rare_colours, refine_around}; @@ -126,12 +126,18 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized // Automatic on flat art: colours too rare for its count, but distinct and drawn as // regions of their own, are added (see `rare_colours`). let detail = (params.color_detail / 100.0).clamp(0.0, 1.0); - let flat_auto = params.palette == Palette::Automatic && flat_colors(from, detail).is_some(); + let flat = if params.palette == Palette::Automatic { flat_count(from, detail) } else { None }; + let flat_auto = flat.is_some(); let mut rare: Vec<[u8; 3]> = vec![]; - // The picture with `colors` colours asked for; fewer come back when it has fewer, or - // when noise made several of one (see `merge_mixed`). - let mut reduce = |colors: u32| { - let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() }); + // The picture in the colors `params` asks for, or in one color per clean level when + // `levels` is given; fewer come back when it has fewer, or when noise made several of + // one (see `merge_mixed`). Also whether `merge_mixed` merged any. + let mut reduce = |levels: Option| { + rare.clear(); + let mut centers = match levels { + Some(n) => flat_palette(from, n), + None => color_palette(from, params), + }; // The anti-aliasing beside a rare color along the cut edge, with the indexes of // that color and of the color on the other side of it (see `rare_colours`). let mut traced_as: Vec = vec![]; @@ -168,9 +174,24 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect(); let q = compact(labels, palette); // A swatch library's colours are used as they are. - if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) } + if library { + return (q, false); + } + let before = q.palette.len(); + let q = merge_mixed(q, img.width as usize, img.height as usize, &rare); + let merged = q.palette.len() < before; + (q, merged) + }; + // Clean levels (see `flat_count`) each take a color unless `merge_mixed` finds two of + // them mixed (dithering, or a thin region of a level beside a close one): such a + // picture takes the colors median cut counts, with rare colors added. + let q = match flat { + Some((_, Some(levels))) => match reduce(Some(levels)) { + (q, false) => q, + (_, true) => reduce(None).0, + }, + _ => reduce(None).0, }; - let q = reduce(params.colors); let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) }; rare.retain(|c| q.palette.contains(c)); (q, rare) diff --git a/crates/trace/src/quantize/palette.rs b/crates/trace/src/quantize/palette.rs index 818aa62e18..43a1954cd7 100644 --- a/crates/trace/src/quantize/palette.rs +++ b/crates/trace/src/quantize/palette.rs @@ -158,7 +158,7 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3] Palette::Limited => limited(), Palette::FullTone => full_tone(), Palette::Automatic => match flat_colors(used, detail) { - Some(n) => kmeans_rgb(used, n, 0.0), + Some(n) => flat_palette(used, n), None => full_tone(), }, Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, ¶ms.swatches, k), @@ -172,9 +172,20 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3] /// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly /// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own. pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option { + flat_count(used, detail).map(|(n, _)| n) +} + +/// Automatic's palette of `n` colors for flat art. +pub(super) fn flat_palette(used: &[Used], n: usize) -> Vec<[f64; 3]> { + kmeans_rgb(used, n, 0.0) +} + +/// [`flat_colors`], and how many clean levels the picture is made of when it is made of them (see +/// [`clean_levels`]). +pub(super) fn flat_count(used: &[Used], detail: f64) -> Option<(usize, Option)> { let total: f64 = used.iter().map(|u| u.2).sum(); if total <= 0.0 { - return Some(2); + return Some((2, None)); } let mut by_count: Vec<&Used> = used.iter().collect(); by_count.sort_by(|a, b| b.2.total_cmp(&a.2)); @@ -195,12 +206,38 @@ pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option { let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect(); let weight: f64 = colours.iter().map(|b| b.weight).sum(); let peaks: f64 = colours.iter().map(|b| b.peak).sum(); - (weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2)) + if weight < 0.9 * total || peaks < 0.5 * weight || colours.len() > 32 { + return None; + } + Some((colours.len().max(2), clean_levels(used, min_share))) } /// The most histogram bins flat art's main colours spread over (a few bins per colour). const FLAT_BINS: usize = 96; +/// The most colors [`clean_levels`] counts. +const CLEAN_LEVELS: usize = 16; + +/// How far apart (levels of some channel) the mean colors of two bins must lie for +/// [`clean_levels`] to count them as two colors. +const LEVELS_APART: f64 = 12.0; + +/// Flat art of 2 to [`CLEAN_LEVELS`] clean colors, one histogram bin each: the number of bins, +/// when each holds at least `min_share` pixels and every two of them lie at least [`LEVELS_APART`] +/// apart on some channel; otherwise `None`. Median cut keeps two colors less than 24 levels apart +/// in one box, so evenly spaced levels 12 to 24 apart would otherwise count as fewer colors. +/// Noise adds bins closer than that, or more than 16. An anti-aliased edge's blended pixels between +/// colors more than 20 levels apart are not among `used` (see `own_colours`), and the steps of a +/// blend between closer colors are less than [`LEVELS_APART`], so such pictures are counted by +/// median cut. +fn clean_levels(used: &[Used], min_share: f64) -> Option { + if !(2..=CLEAN_LEVELS).contains(&used.len()) || used.iter().any(|u| u.2 < min_share) { + return None; + } + let apart = |a: [f64; 3], b: [f64; 3]| (0..3).any(|ch| (a[ch] - b[ch]).abs() >= LEVELS_APART); + used.iter().enumerate().all(|(i, a)| used[i + 1..].iter().all(|b| apart(a.1, b.1))).then_some(used.len()) +} + /// Document Library: the `k` swatches (at most) that the most pixels are nearest to (see /// [`nearest_library`]). fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> { @@ -438,6 +475,53 @@ mod tests { assert_eq!(palette(Palette::Automatic, 16), automatic); } + /// `colors`, each with `n` pixels, as histogram bins. + fn bins(colors: impl IntoIterator) -> Vec { + colors.into_iter().enumerate().map(|(i, (c, n))| (i, c, n)).collect() + } + + /// `levels` grays evenly spaced from black to white, 1,200 pixels each. + fn gray_levels(levels: u32) -> Vec<([f64; 3], f64)> { + (0..levels).map(|i| ([f64::from(i * 255 / (levels - 1)); 3], 1200.0)).collect() + } + + /// The clean levels `used` is made of, at the default Color Detail (see [`flat_count`]). + fn clean(used: &[Used]) -> Option { + flat_count(used, (TraceParams::default().color_detail / 100.0).clamp(0.0, 1.0)).and_then(|(_, levels)| levels) + } + + #[test] + fn automatic_counts_every_clean_level_of_flat_art() { + for levels in [2, 3, 10, 12, 14, 16] { + assert_eq!(clean(&bins(gray_levels(levels))), Some(levels as usize), "{levels} grays"); + } + // Sixteen sepia levels, about 13 apart on each channel. + let sepia = (0..16u32).map(|i| { + let t = f64::from(i) / 15.0; + ([45.0 + 205.0 * t, 28.0 + 207.0 * t, 15.0 + 190.0 * t].map(f64::round), 1200.0) + }); + assert_eq!(clean(&bins(sepia)), Some(16), "16 sepia levels"); + // Levels 12 apart are the closest counted. + let close = |apart: f64| bins((0..16).map(|i| ([40.0 + apart * f64::from(i); 3], 1200.0))); + assert_eq!(clean(&close(12.0)), Some(16), "16 grays 12 apart"); + assert_eq!(clean(&close(11.0)), None, "16 grays 11 apart"); + } + + #[test] + fn noise_and_anti_aliasing_add_no_clean_levels() { + // Eight grays 34 apart, each with a second bin 6 levels off it holding as many pixels (noise). + let noisy = gray_levels(8).into_iter().flat_map(|(c, n)| [(c, n), (c.map(|v| (v + 6.0).min(255.0)), n)]); + assert_eq!(clean(&bins(noisy)), None, "eight noisy grays"); + // Eight grays with a few blended pixels halfway between each two (anti-aliasing). + let levels = gray_levels(8); + let blends = levels.windows(2).map(|w| ([(w[0].0[0] + w[1].0[0]) / 2.0; 3], 60.0)); + assert_eq!(clean(&bins(levels.iter().copied().chain(blends))), None, "eight anti-aliased grays"); + // Seventeen levels or more are counted by median cut. + assert_eq!(clean(&bins((0..17).map(|i| ([15.0 * f64::from(i); 3], 1200.0)))), None, "17 grays"); + // A continuous-tone picture has no count at all. + assert_eq!(clean(&ground_and_ramp()), None, "ground and ramp"); + } + #[test] fn a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color() { // Dark navy text (a few pixels) on the near-black ground: only cuts between two dark grays diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 2e24eb80bb..85e9d09778 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -2322,6 +2322,65 @@ fn thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced() { } } +#[test] +fn automatic_keeps_every_level_of_clean_flat_art_of_up_to_sixteen_levels() { + let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)]; + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + for (name, stops) in tints { + for levels in [2, 10, 12, 14, 16] { + let bands = tinted(&even_gray_bands(levels), stops, 0.0); + let res = trace(&bands, ¶ms); + assert_eq!(res.palette.len(), levels as usize, "{levels} {name} bands: {:?}", res.palette); + for band in 0..levels { + let i = ((band * 20 + 10) * 4) as usize; + let want = [bands.rgba[i], bands.rgba[i + 1], bands.rgba[i + 2]]; + assert!( + res.palette.iter().any(|c| (0..3).all(|k| c[k].abs_diff(want[k]) <= 1)), + "{levels} {name} bands: no {want:?} in {:?}", + res.palette + ); + } + assert_eq!(res.paths.len(), levels as usize, "{levels} {name} bands"); + } + } +} + +#[test] +fn automatic_counts_no_anti_aliased_edge_of_clean_levels_as_a_level() { + // Four gray bands 10 px wide, `apart` levels apart, each edge blended over `width` px: every + // blended column holds 2.5 in 100 of the pixels, more than a color needs to be counted. + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + for apart in [24.0, 40.0, 64.0] { + for width in 1..=4u32 { + let level = |b: u32| 20.0 + apart * f64::from(b); + let img = Raster::from_fn(40, 60, |x, _| { + let (b, j) = (x / 10, x % 10); + let v = if b > 0 && j < width { level(b - 1) + apart * f64::from(j + 1) / f64::from(width + 1) } else { level(b) }; + let v = v.round() as u8; + [v, v, v, 255] + }); + let res = trace(&img, ¶ms); + assert_eq!(res.palette.len(), 4, "levels {apart} apart, edges {width} px: {:?}", res.palette); + } + } +} + +#[test] +fn automatic_merges_a_dithered_close_level_as_noise() { + // A gray ground with 5 to 20 in 100 of its pixels, scattered, 12 levels lighter: two clean + // levels by their histogram, but mixed through each other, so they trace as one color. + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + let hash = |x: u32, y: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761); + for percent in [5, 10, 20] { + let img = Raster::from_fn(120, 120, |x, y| { + let v = if hash(x, y) % 100 < percent { 112 } else { 100 }; + [v, v, v, 255] + }); + let res = trace(&img, ¶ms); + assert_eq!(res.palette.len(), 1, "{percent} in 100 dithered: {:?}", res.palette); + } +} + #[test] fn pictures_without_tones_are_not_continuous_tone() { let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0); diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index 806434fa1f..b0bdcc6287 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -56,6 +56,10 @@ pub struct Job { pub progress: Option>, /// Continue in Background: the progress dialog no longer shows this job. pub hidden: bool, + /// [`crate::state::UiState::status_sets`] before the job set its status line. + sets: u64, + /// The status line the job set ("