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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:07:51 +07:00
parent 059f623410
commit 93640efb36
6 changed files with 225 additions and 32 deletions

View File

@ -280,6 +280,18 @@ impl GradientGeom {
self.aspect = if len > 1e-12 { other / len } else { 1.0 }; 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 /// 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. /// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
@ -332,6 +344,12 @@ impl GradientPaint {
self.geom = Some(self.resolve(bounds)); 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`]). /// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
pub fn transform(&mut self, a: Affine) { pub fn transform(&mut self, a: Affine) {
if let Some(g) = &mut self.geom { if let Some(g) = &mut self.geom {
@ -514,6 +532,21 @@ mod tests {
assert_eq!(v[0].midpoint, 0.87); 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] #[test]
fn pin_fixes_the_fit_once() { fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default()); let mut p = GradientPaint::new(Gradient::default());

View File

@ -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`. /// Map placed gradients through `a`.
pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) { pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) {
for g in self.gradients_mut() { for g in self.gradients_mut() {
@ -594,6 +611,25 @@ mod tests {
assert_eq!(a.stroke_width(), 1.0); 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] #[test]
fn items_by_index() { fn items_by_index() {
let mut a = Appearance::default_art(); let mut a = Appearance::default_art();

View File

@ -259,6 +259,18 @@ impl Default for GridPrefs {
pub struct GraphicStyle { pub struct GraphicStyle {
pub name: String, pub name: String,
pub appearance: Appearance, 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<String>, appearance: Appearance) -> Self {
Self { name: name.into(), appearance, unit_box: true }
}
} }
/// A symbol definition. /// A symbol definition.
@ -636,16 +648,13 @@ impl Document {
fn default_graphic_styles() -> Vec<GraphicStyle> { fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
vec![ vec![
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() }, GraphicStyle::new("Default Graphic Style", Appearance::default_art()),
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) }, GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
GraphicStyle { GraphicStyle::new("Heavy Ink", Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0)),
name: "Heavy Ink".into(), GraphicStyle::new(
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0), "Sunshine",
}, Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
GraphicStyle { ),
name: "Sunshine".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
},
] ]
} }

View File

@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper", "Eyedropper",
[], [],
None, 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, has_doc,
copy_from copy_from
), ),
@ -461,15 +461,25 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; 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 src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind { // The appearance and the box its placed gradients are relative to (type: its first run's
NodeKind::Text(t) => match t.runs.first() { // paints, in text space).
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), let (appearance, src_box) = match &src.kind {
None => src.appearance.clone(), NodeKind::Text(t) if !t.runs.is_empty() => {
}, let r = &t.runs[0];
_ => src.appearance.clone(), (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(); // `appearance` placed on an object whose box (in its paint space) is `to`.
s.paint.stroke = appearance.stroke_paint(); let placed = |to: Option<vectorcraft_geom::Rect>| {
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()); s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() { if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width(); s.paint.stroke_width = appearance.stroke_width();
@ -490,14 +500,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
n.opacity = opacity; n.opacity = opacity;
n.blend = blend; n.blend = blend;
if let NodeKind::Text(t) = &mut n.kind { if let NodeKind::Text(t) = &mut n.kind {
let ap = placed(Some(t.local_bounds()));
for r in &mut t.runs { for r in &mut t.runs {
r.style.fill = appearance.fill_paint(); r.style.fill = ap.fill_paint();
r.style.stroke = appearance.stroke_paint(); r.style.stroke = ap.stroke_paint();
r.style.stroke_width = appearance.stroke_width(); r.style.stroke_width = ap.stroke_width();
} }
continue; continue;
} }
n.appearance = appearance.clone(); n.appearance = placed(n.geometric_bounds());
} }
Ok(()) Ok(())
})?; })?;

View File

@ -1,14 +1,34 @@
//! The Graphic Styles panel: apply, new, delete and duplicate. //! The Graphic Styles panel: apply, new, delete and duplicate.
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use super::*; use super::*;
use crate::EngineError; 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<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), cmd!(
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), "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.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), cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate),
] ]
@ -16,19 +36,25 @@ pub fn specs() -> Vec<CommandSpec> {
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> { fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s let style = s
.doc()? .doc()?
.doc .doc
.graphic_styles .graphic_styles
.iter() .iter()
.find(|g| g.name == name) .find(|g| g.name == name)
.map(|g| g.appearance.clone()) .cloned()
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?; let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| { s.edit("Apply Graphic Style", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { 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(()) Ok(())
@ -39,10 +65,20 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
fn style_new(s: &mut Session, p: &Value) -> Result<Value> { fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
let id = *st.selection.objects.first().unwrap(); 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)); 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, _| { 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(())
})?; })?;
Ok(json!({ "name": name })) Ok(json!({ "name": name }))

View File

@ -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 serde_json::json;
use vectorcraft_color::Paint; use vectorcraft_color::{GradientGeom, Paint};
use vectorcraft_geom::Point;
use super::*; 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()]) (vec!["#00ff00".into(), "#000000".into()], vec!["#ffffff".into(), "#000000".into()])
); );
} }
fn fill_geom(s: &Session, id: NodeId) -> Option<GradientGeom> {
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");
}