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/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 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 5cf923eb99..c6aed0a644 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -899,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/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/graph.rs b/crates/engine/src/cmd/graph.rs index 06557e6f59..118c4cd04f 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::{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 77ce7a7b2a..0d914ad8ff 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, @@ -169,6 +169,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 5fadcb8768..247dc92346 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/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4ce578d505..67d0fec2a3 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -694,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/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/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/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 1adfaf0d4b..6f77c141ac 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -84,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; @@ -94,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}; 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/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 ("