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:
parent
059f623410
commit
93640efb36
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String>, 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<GraphicStyle> {
|
||||
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),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<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 = 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<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());
|
||||
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<Value> {
|
|||
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(())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
|||
|
||||
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 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<Value> {
|
|||
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 }))
|
||||
|
|
|
|||
|
|
@ -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<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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue