mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
M3.98: radial annotator extent ellipse, aspect handle and off-centre focal point
Radial gradients show a dashed extent ellipse: its handle across the bar sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside the centre ring drags the focal point (GradientGeom.focal) for an off-centre radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and start/end may be left out; the focal point keeps its place in the ellipse as the vector, aspect and object transform. It renders as a two-point radial, exports and imports as SVG fx/fy and PDF two-point radial shadings, expands to off-centre rings, and travels in gradient params and the native format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b8793eeaef
commit
d8eff17b9f
@@ -229,7 +229,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
|
||||
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
|
||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 });
|
||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(g))
|
||||
};
|
||||
let circle = |centre: Point, radius: f64| shapes::ellipse(Rect::from_center_size(centre, (radius * 2.0, radius * 2.0)));
|
||||
|
||||
@@ -314,10 +314,11 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32
|
||||
/// A linear or radial gradient's geometry over an object's box: its start, the vector to its
|
||||
/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that
|
||||
/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial:
|
||||
/// `0..t1`).
|
||||
/// `0..t1`, each level's ellipse centred on the way from the focal point to the start).
|
||||
struct Frame {
|
||||
radial: bool,
|
||||
start: Point,
|
||||
focal: Point,
|
||||
along: Vec2,
|
||||
across: Vec2,
|
||||
t0: f64,
|
||||
@@ -344,13 +345,17 @@ impl Frame {
|
||||
}
|
||||
// Keep a sliver of span so the art maps back invertibly.
|
||||
t1 = t1.max(t0 + 1e-6);
|
||||
Self { radial, start: geom.start, along, across, t0, t1, s0, s1 }
|
||||
Self { radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 }
|
||||
}
|
||||
|
||||
/// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t`
|
||||
/// and angle `s` (radians).
|
||||
fn at(&self, t: f64, s: f64) -> Point {
|
||||
if self.radial { self.start + (self.along * s.cos() + self.across * s.sin()) * t } else { self.start + self.along * t + self.across * s }
|
||||
if self.radial {
|
||||
self.focal + (self.start - self.focal + self.along * s.cos() + self.across * s.sin()) * t
|
||||
} else {
|
||||
self.start + self.along * t + self.across * s
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour
|
||||
@@ -371,7 +376,7 @@ impl Frame {
|
||||
|
||||
/// `n` solid shapes covering the box in paint order, each coloured as the gradient in the
|
||||
/// middle of its band: for a linear gradient, rectangles from each band's start to the far
|
||||
/// end, for a radial one, concentric ellipses (largest first). Each paints over the ones
|
||||
/// end, for a radial one, nested ellipses (largest first). Each paints over the ones
|
||||
/// below except its band, so no seam shows between bands. With translucent stops each shape
|
||||
/// is just its band, so nothing doubles up.
|
||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||
@@ -380,7 +385,8 @@ impl Frame {
|
||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if self.radial {
|
||||
let ellipse = |t: f64| {
|
||||
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, self.start.x, self.start.y]);
|
||||
let c = self.focal + (self.start - self.focal) * t;
|
||||
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]);
|
||||
shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m)
|
||||
};
|
||||
return (0..n)
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient Vector",
|
||||
[],
|
||||
None,
|
||||
"{start: [x,y], end: [x,y] (document coordinates), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector (solid paints become the default gradient; type objects set it on their runs, in text space; the aspect ratio is kept)",
|
||||
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial: the extent ellipse's height / width; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
@@ -181,16 +181,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||
let aspect = aspect_param(g)?;
|
||||
let point = |k: &str| g.get(k).map(|_| point_param(g, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose();
|
||||
match (point("start")?, point("end")?) {
|
||||
(Some(start), Some(end)) => {
|
||||
let geom = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0) };
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return Err("give both `start` and `end` (or neither)".into()),
|
||||
let geom = GeomEdit::parse(g)?;
|
||||
if let Some((start, end)) = geom.vector {
|
||||
let mut g = GradientGeom { start, end, aspect: geom.aspect.unwrap_or(1.0), focal: None };
|
||||
g.set_focal(geom.focal.flatten());
|
||||
gp.angle = g.angle_deg();
|
||||
gp.geom = Some(g);
|
||||
}
|
||||
if let Some(f) = g.get("freeform") {
|
||||
gp.freeform = Some(super::freeform::parse_freeform(f)?);
|
||||
@@ -282,6 +278,8 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
|
||||
}
|
||||
}
|
||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||
let before = gp.geom;
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
gp.angle = a;
|
||||
@@ -308,6 +306,9 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
g.aspect = asp;
|
||||
}
|
||||
}
|
||||
if let (Some(b), Some(g)) = (before, &mut gp.geom) {
|
||||
g.keep_focal_from(&b);
|
||||
}
|
||||
if gp.gradient.kind == GradientKind::Freeform {
|
||||
// Points already shown (automatic ones fitted to the box) stay put; a fresh freeform
|
||||
// gradient gets its points inside the shape.
|
||||
@@ -428,10 +429,35 @@ fn invert(a: Affine) -> Option<Affine> {
|
||||
(a.determinant().abs() > 1e-12).then(|| a.inverse())
|
||||
}
|
||||
|
||||
/// `cur` as a gradient whose vector runs from `start` to `end` in the document. `to_doc` maps the
|
||||
/// paint's space (text space for type runs) to the document and `bounds` (in that space) fits an
|
||||
/// unplaced gradient, so the aspect ratio the document shows carries over.
|
||||
fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: Option<Rect>) -> Option<Paint> {
|
||||
/// What `paint.setGradientGeom` sets, in document coordinates (None: kept).
|
||||
struct GeomEdit {
|
||||
vector: Option<(Point, Point)>,
|
||||
aspect: Option<f64>,
|
||||
/// Some(None) centres the focal point.
|
||||
focal: Option<Option<Point>>,
|
||||
}
|
||||
|
||||
impl GeomEdit {
|
||||
fn parse(p: &Value) -> std::result::Result<Self, String> {
|
||||
let point = |k: &str| p.get(k).map(|_| point_param(p, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose();
|
||||
let vector = match (point("start")?, point("end")?) {
|
||||
(Some(s), Some(e)) => Some((s, e)),
|
||||
(None, None) => None,
|
||||
_ => return Err("give both `start` and `end` [x, y] (or neither)".into()),
|
||||
};
|
||||
let focal = match p.get("focal") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(_) => Some(point("focal")?),
|
||||
};
|
||||
Ok(Self { vector, aspect: aspect_param(p)?, focal })
|
||||
}
|
||||
}
|
||||
|
||||
/// `cur` with the vector, aspect and focal point of `e` (given in the document). `to_doc` maps
|
||||
/// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits
|
||||
/// an unplaced gradient, so what the document shows carries over where `e` keeps it.
|
||||
fn vector_paint(cur: &Paint, e: &GeomEdit, to_doc: Affine, bounds: Option<Rect>) -> Option<Paint> {
|
||||
let from_doc = invert(to_doc)?;
|
||||
let mut gp = match cur {
|
||||
Paint::Gradient(g) => (**g).clone(),
|
||||
@@ -444,8 +470,17 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O
|
||||
}
|
||||
g
|
||||
};
|
||||
let aspect = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map_or(1.0, |g| map(g, to_doc).aspect);
|
||||
let geom = map(GradientGeom { start, end, aspect }, from_doc);
|
||||
let shown = gp.geom.or_else(|| bounds.map(|b| gp.resolve(b))).map(|g| map(g, to_doc));
|
||||
let (start, end) = e.vector.or_else(|| shown.map(|g| (g.start, g.end)))?;
|
||||
let aspect = e.aspect.or_else(|| shown.map(|g| g.aspect)).unwrap_or(1.0);
|
||||
let mut geom = GradientGeom { start, end, aspect, focal: None };
|
||||
match (e.focal, shown) {
|
||||
_ if kind != GradientKind::Radial => {}
|
||||
(Some(f), _) => geom.set_focal(f),
|
||||
(None, Some(b)) => geom.keep_focal_from(&b),
|
||||
(None, None) => {}
|
||||
}
|
||||
let geom = map(geom, from_doc);
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
Some(Paint::Gradient(Box::new(gp)))
|
||||
@@ -453,8 +488,7 @@ fn vector_paint(cur: &Paint, start: Point, end: Point, to_doc: Affine, bounds: O
|
||||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.setGradientGeom";
|
||||
let start = point_param(p, "start").ok_or_else(|| bad(C, "missing start [x,y]"))?;
|
||||
let end = point_param(p, "end").ok_or_else(|| bad(C, "missing end [x,y]"))?;
|
||||
let edit = GeomEdit::parse(p).map_err(|e| bad(C, e))?;
|
||||
// `index` is an alias of `item`.
|
||||
let mut p = p.clone();
|
||||
if let (None, Some(i)) = (p.get("item"), p.get("index").cloned()) {
|
||||
@@ -481,14 +515,14 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_stroke_weight(&mut r.style);
|
||||
}
|
||||
let (paint, b) = run_paint_mut(r, stroke, lb);
|
||||
if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) {
|
||||
if let Some(np) = vector_paint(paint, &edit, xf, Some(b)) {
|
||||
*paint = np;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let b = item_paint_bounds(n, index, !stroke);
|
||||
if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), start, end, Affine::IDENTITY, b) {
|
||||
if let Some(np) = vector_paint(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), &edit, Affine::IDENTITY, b) {
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user