From 93640efb36e12c4a966b734a79e5535935b309bf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:07:51 +0700 Subject: [PATCH] M3.34: copied appearances place their gradients on the target's own bounds The eyedropper (`appearance.copyFrom`), `graphicStyle.new` and `graphicStyle.apply` move placed gradients so they sit at the same place relative to each target's bounds (type: its layout box in text space) instead of where the source had them; the paint defaults take the gradient unplaced, so new art fits it. Model: `GradientGeom::rebase` / `GradientPaint::rebase` (color), `Appearance::rebase_gradients` (strokes relative to their inflated boxes) and `GraphicStyle::unit_box`: new styles store placed gradients in unit-box space; styles from older files keep document coordinates and apply as before. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 33 +++++++++++++ crates/doc/src/appearance.rs | 36 ++++++++++++++ crates/doc/src/lib.rs | 29 +++++++---- crates/engine/src/cmd/appearance.rs | 37 +++++++++----- crates/engine/src/cmd/style.rs | 50 ++++++++++++++++--- crates/engine/src/tests_gradpanel.rs | 72 +++++++++++++++++++++++++++- 6 files changed, 225 insertions(+), 32 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index b82df7cf..4fec8988 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -280,6 +280,18 @@ impl GradientGeom { self.aspect = if len > 1e-12 { other / len } else { 1.0 }; } + /// Move the placement from box `from` to box `to`: the start and end keep their position + /// relative to the box (along a side `from` has no extent on, their offset from its centre). + /// The aspect ratio is kept. + pub fn rebase(&mut self, from: Rect, to: Rect) { + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| { + if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 } + }; + let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1)); + self.start = map(self.start); + self.end = map(self.end); + } + /// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial. pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { @@ -332,6 +344,12 @@ impl GradientPaint { self.geom = Some(self.resolve(bounds)); } } + /// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]). + pub fn rebase(&mut self, from: Rect, to: Rect) { + if let Some(g) = &mut self.geom { + g.rebase(from, to); + } + } /// Map a placed gradient through `a` (see [`GradientGeom::transform`]). pub fn transform(&mut self, a: Affine) { if let Some(g) = &mut self.geom { @@ -514,6 +532,21 @@ mod tests { assert_eq!(v[0].midpoint, 0.87); } + #[test] + fn rebase_keeps_relative_positions() { + let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 }; + g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0)); + assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}"); + // A box without height (a horizontal line) keeps the offset from its centre line. + let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 }; + h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0)); + assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}"); + // Unplaced paints have nothing to move. + let mut p = GradientPaint::new(Gradient::default()); + p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0)); + assert_eq!(p.geom, None); + } + #[test] fn pin_fixes_the_fit_once() { let mut p = GradientPaint::new(Gradient::default()); diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 53dde758..98e9604a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -564,6 +564,23 @@ impl Appearance { } } } + /// Move placed gradients from an object whose geometric bounds are `from` to one whose bounds + /// are `to`, keeping them at the same place relative to the box (strokes: relative to their + /// stroke-inflated boxes, as they fit). + pub fn rebase_gradients(&mut self, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect) { + for i in &mut self.items { + match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.rebase(from, to), + AppearanceItem::Stroke(s) => { + let (f, t) = (s.paint_bounds(from), s.paint_bounds(to)); + if let Paint::Gradient(g) = &mut s.paint { + g.rebase(f, t); + } + } + _ => {} + } + } + } /// Map placed gradients through `a`. pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { for g in self.gradients_mut() { @@ -594,6 +611,25 @@ mod tests { assert_eq!(a.stroke_width(), 1.0); } + #[test] + fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint}; + use vectorcraft_geom::{Point, Rect}; + let placed = |x: f64| { + let mut g = GradientPaint::new(Gradient::default()); + g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 }); + Paint::Gradient(Box::new(g)) + }; + let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0); + a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0)); + let start = |p: Paint| match p { + Paint::Gradient(g) => g.geom.unwrap().start, + _ => unreachable!(), + }; + // The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95). + assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0))); + } + #[test] fn items_by_index() { let mut a = Appearance::default_art(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b242ae03..a0a2bb62 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -259,6 +259,18 @@ impl Default for GridPrefs { pub struct GraphicStyle { pub name: String, pub appearance: Appearance, + /// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style + /// places them on each object's own bounds. Styles saved before this kept document + /// coordinates (false). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub unit_box: bool, +} + +impl GraphicStyle { + /// A style whose placed gradients (if any) are in unit-box space. + pub fn new(name: impl Into, appearance: Appearance) -> Self { + Self { name: name.into(), appearance, unit_box: true } + } } /// A symbol definition. @@ -636,16 +648,13 @@ impl Document { fn default_graphic_styles() -> Vec { use vectorcraft_color::{Color, Paint}; vec![ - GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() }, - GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) }, - GraphicStyle { - name: "Heavy Ink".into(), - appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0), - }, - GraphicStyle { - name: "Sunshine".into(), - appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0), - }, + GraphicStyle::new("Default Graphic Style", Appearance::default_art()), + GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)), + GraphicStyle::new("Heavy Ink", Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0)), + GraphicStyle::new( + "Sunshine", + Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0), + ), ] } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 0a5010da..31fa965a 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", + "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)", has_doc, copy_from ), @@ -461,15 +461,25 @@ fn move_item(s: &mut Session, p: &Value) -> Result { fn copy_from(s: &mut Session, p: &Value) -> Result { let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; - let appearance = match &src.kind { - NodeKind::Text(t) => match t.runs.first() { - Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), - None => src.appearance.clone(), - }, - _ => src.appearance.clone(), + // The appearance and the box its placed gradients are relative to (type: its first run's + // paints, in text space). + let (appearance, src_box) = match &src.kind { + NodeKind::Text(t) if !t.runs.is_empty() => { + let r = &t.runs[0]; + (Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds())) + } + _ => (src.appearance.clone(), src.geometric_bounds()), }; - s.paint.fill = appearance.fill_paint(); - s.paint.stroke = appearance.stroke_paint(); + // `appearance` placed on an object whose box (in its paint space) is `to`. + let placed = |to: Option| { + let mut ap = appearance.clone(); + if let (Some(f), Some(t)) = (src_box, to) { + ap.rebase_gradients(f, t); + } + ap + }; + s.paint.fill = super::gradient::unplaced(&appearance.fill_paint()); + s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint()); s.remember_paint(&s.paint.fill.clone()); if appearance.stroke().is_some() { s.paint.stroke_width = appearance.stroke_width(); @@ -490,14 +500,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { n.opacity = opacity; n.blend = blend; if let NodeKind::Text(t) = &mut n.kind { + let ap = placed(Some(t.local_bounds())); for r in &mut t.runs { - r.style.fill = appearance.fill_paint(); - r.style.stroke = appearance.stroke_paint(); - r.style.stroke_width = appearance.stroke_width(); + r.style.fill = ap.fill_paint(); + r.style.stroke = ap.stroke_paint(); + r.style.stroke_width = ap.stroke_width(); } continue; } - n.appearance = appearance.clone(); + n.appearance = placed(n.geometric_bounds()); } Ok(()) })?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6e8d80b0..18d9abd0 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,14 +1,34 @@ //! The Graphic Styles panel: apply, new, delete and duplicate. use serde_json::{Value, json}; +use vectorcraft_geom::Rect; use super::*; use crate::EngineError; +/// The box a style's placed gradients are stored relative to. +const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0); + pub fn specs() -> Vec { vec![ - cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), - cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), + cmd!( + "graphicStyle.apply", + "Apply Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name, ids?} give the selection (or ids) the style's appearance; its placed gradients land at the same place relative to each object's bounds", + has_selection, + style_apply + ), + cmd!( + "graphicStyle.new", + "New Graphic Style", + ["Window", "Graphic Styles"], + None, + "{name?} from the first selected object's appearance (placed gradients are kept relative to its bounds) → {name}", + has_selection, + style_new + ), cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), ] @@ -16,19 +36,25 @@ pub fn specs() -> Vec { fn style_apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; - let ap = s + let style = s .doc()? .doc .graphic_styles .iter() .find(|g| g.name == name) - .map(|g| g.appearance.clone()) + .cloned() .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; let ids = paint_targets(s, p)?; s.edit("Apply Graphic Style", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - n.appearance = ap.clone(); + let mut ap = style.appearance.clone(); + if style.unit_box + && let Some(b) = n.geometric_bounds() + { + ap.rebase_gradients(UNIT_BOX, b); + } + n.appearance = ap; } } Ok(()) @@ -39,10 +65,20 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { fn style_new(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = *st.selection.objects.first().unwrap(); - let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); + let ap = st + .doc + .node(id) + .map(|n| { + let mut ap = n.appearance.clone(); + if let Some(b) = n.geometric_bounds() { + ap.rebase_gradients(b, UNIT_BOX); + } + ap + }) + .unwrap_or_default(); let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); s.edit("New Graphic Style", |d, _| { - d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle::new(name.clone(), ap)); Ok(()) })?; Ok(json!({ "name": name })) diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs index 48551f2d..37bebce0 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests_gradpanel.rs @@ -1,7 +1,9 @@ -//! The Gradient panel's engine side (M3.33): sampling a colour into a gradient stop. +//! Gradient panel and gradient behaviour: sampling a colour into a gradient stop (M3.33) and +//! copied appearances placing their gradients on the target's own bounds (M3.34). use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{GradientGeom, Paint}; +use vectorcraft_geom::Point; use super::*; @@ -45,3 +47,69 @@ fn sample_color_with_a_stop_recolours_that_stop_in_one_undo_step() { (vec!["#00ff00".into(), "#000000".into()], vec!["#ffffff".into(), "#000000".into()]) ); } + +fn fill_geom(s: &Session, id: NodeId) -> Option { + match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => g.geom, + p => panic!("expected a gradient, got {p:?}"), + } +} + +fn close(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 +} + +/// A 100 × 100 source at (10, 10) with a placed gradient from 20 % to 80 % across its middle, +/// and a 200 × 100 target at x = 500. +fn source_and_target() -> (Session, NodeId, NodeId) { + let mut s = session(); + let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"start": [30, 60], "end": [90, 60]}})).unwrap(); + let dst = rect(&mut s, 500.0, 10.0, 200.0, 100.0); + (s, src, dst) +} + +#[test] +fn the_eyedropper_places_copied_gradients_on_the_target() { + let (mut s, src, dst) = source_and_target(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap(); + let g = fill_geom(&s, dst).expect("placed"); + assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}"); + // The source keeps its own; new art fits the gradient to itself. + assert!(close(fill_geom(&s, src).unwrap().start, Point::new(30.0, 60.0))); + assert!(matches!(&s.paint.fill, Paint::Gradient(g) if g.geom.is_none())); + // The eyedropper tool's click runs the same command. + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 50.0, 50.0); + s.pointer(&ev, ViewInfo::default()).unwrap(); + assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(540.0, 60.0))); +} + +#[test] +fn graphic_styles_keep_gradients_relative_to_the_bounds() { + let (mut s, src, dst) = source_and_target(); + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap(); + let style = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap(); + let Paint::Gradient(g) = style.appearance.fill_paint() else { panic!() }; + assert!(style.unit_box && close(g.geom.unwrap().start, Point::new(0.2, 0.5)), "stored in unit-box space: {g:?}"); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Glow"})).unwrap(); + let g = fill_geom(&s, dst).unwrap(); + assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}"); + // The flag survives a save; a style from an older file (no flag) keeps document coordinates. + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.graphic_styles, s.doc().unwrap().doc.graphic_styles); + let legacy: vectorcraft_doc::GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": style.appearance})).unwrap(); + assert!(!legacy.unit_box); + s.edit("Add", |d, _| { + d.graphic_styles.push(legacy); + Ok(()) + }) + .unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Old"})).unwrap(); + assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored"); +}