From 06031b7c9dad88fbd869aa9517e5b2ca53910840 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:35:01 -0800 Subject: [PATCH] Graphs: a series mark's gradient written unchanged leaves the kept gradient A command that writes a series mark's paint compared the mark's gradient with the series' kept gradient placed on the mark's box exactly. After the mark was moved, the two differed by rounding, so the graph stored a slightly different gradient and recorded an undo step. The vector, focal point and freeform points now match within 1e-6 pt. --- crates/engine/src/cmd/graph/series.rs | 37 ++++++++++++++++++++++++++- crates/engine/src/cmd/graph/tests.rs | 13 ++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 1c0a6b1828..3936900ad9 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -282,6 +282,41 @@ fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option) -> Some(Paint::Gradient(g)) } +/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the +/// rounding a move or a placement on another box leaves). +fn same_paint_placed(a: &Paint, b: &Paint) -> bool { + let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b }; + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6; + let mut a = (**a).clone(); + match (&mut a.geom, &b.geom) { + (Some(ag), Some(bg)) => { + let focal = match (ag.focal, bg.focal) { + (Some(p), Some(q)) => near(p, q), + (p, q) => p.is_none() && q.is_none(), + }; + if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) { + return false; + } + (ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal); + } + (None, None) => {} + _ => return false, + } + match (&mut a.freeform, &b.freeform) { + (Some(af), Some(bf)) => { + if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) { + return false; + } + for (p, q) in af.points.iter_mut().zip(&bf.points) { + p.at = q.at; + } + } + (None, None) => {} + _ => return false, + } + a == **b +} + /// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown /// by half the weight for a stroke. None for a box with no width or no height, which a gradient's place cannot be kept /// relative to. @@ -361,7 +396,7 @@ fn store_series_paints(doc: &mut Document, captures: Vec) -> bool { } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; // A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is. - let drawn = kept_on(slot, fill, from).is_some_and(|kept| kept == paint); + let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint)); let (paint, unit) = unit_box_paint(paint, from); if fill { if !drawn { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 36f68b9b33..f3ec2dbeaa 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2702,6 +2702,19 @@ fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() { assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + // A mark moved with its gradient shows the kept gradient on its moved box, up to rounding. + let kept = spec(&s, id).series_paints[0].clone(); + for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() { + s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap(); + let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap(); + assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id); + let undo = s.doc().unwrap().history.undo.len(); + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } } /// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the