mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
M3.83: gradients within, along or across a stroke
The Gradient panel's Stroke buttons now work: with the Stroke proxy on a
linear or radial gradient they lay it within the stroke (placed on the
page, as before), along the path (start to end of each subpath) or across
it (left edge to right edge; inside/outside strokes span their visible
side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art
too with nothing selected); `document.inspect` reports
strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default
Within, not written).
Shared geometry in effects::stroke::gradient: ribs on the bisectors of
the flattened path (fans round sharp corners, widened under arrowheads,
following width profiles) cut slices each painted with a linear piece of
the gradient (Gradient::span). The canvas and raster export clip them to
the stroke's outline and arrowheads, grown a pixel and composited with
copy so there are no seams and translucent stops paint once. SVG and PDF
write the same slices clipped to the outline, with a warning (new
vectorcraft_svg::export_with_report). Outline Stroke and Expand make
gradient meshes clipped to the outline. Mesh quads now grow along their
edges so thin quads show no seams. Dashes, profiles and arrowheads keep
working in both modes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ccdb1b30f6
commit
025d758abe
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_tools::params::color_json;
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within)} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -339,6 +339,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||
let mode = match str_param(p, "strokeMode") {
|
||||
None => None,
|
||||
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
||||
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
||||
};
|
||||
// Stops naming swatches take their colours from them.
|
||||
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
@@ -365,8 +371,19 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
if let Some(m) = mode
|
||||
&& let Some(st) = n.appearance.stroke_at_mut(index)
|
||||
{
|
||||
st.gradient_mode = m;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(m) = mode
|
||||
&& ids.is_empty()
|
||||
&& p.get("ids").is_none()
|
||||
{
|
||||
s.new_art_stroke_mut().gradient_mode = m;
|
||||
}
|
||||
// The last gradient is the one the first object now shows (the defaults' without objects).
|
||||
let shown = match ids.first() {
|
||||
Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())),
|
||||
|
||||
@@ -459,7 +459,8 @@ pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, ble
|
||||
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
|
||||
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
|
||||
/// and effects. `None` when it paints nothing.
|
||||
/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
|
||||
/// like the outline). `None` when it paints nothing.
|
||||
pub(crate) fn outlined_stroke(
|
||||
d: &mut Document,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
@@ -481,6 +482,18 @@ pub(crate) fn outlined_stroke(
|
||||
if outline.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(g) = st.path_gradient() {
|
||||
// A gradient along or across the stroke: gradient meshes clipped to its outline.
|
||||
let meshes = vectorcraft_render::effects::stroke::gradient_meshes(&path.to_bezpath(), rule, st, &g.gradient);
|
||||
let mut clip = shape_node(d, outline, None);
|
||||
super::object::as_clipping_path(&mut clip).ok()?;
|
||||
let meshes: Vec<Node> = meshes.into_iter().map(|m| Node::new(d.alloc_id(), NodeKind::Mesh(m))).collect();
|
||||
let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect();
|
||||
let group = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
|
||||
let mut art = with_transparency(d, group, st.opacity, st.blend);
|
||||
art.appearance.effects = st.effects.clone();
|
||||
return Some(art);
|
||||
}
|
||||
let mut n = shape_node(d, outline, None);
|
||||
let fill = vectorcraft_doc::appearance::FillLayer {
|
||||
opacity: st.opacity,
|
||||
|
||||
@@ -96,7 +96,8 @@ pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
|
||||
|
||||
/// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when
|
||||
/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile,
|
||||
/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform).
|
||||
/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform)
|
||||
/// and gradientMode: how a gradient lies on it (`paint.editGradient` strokeMode).
|
||||
pub fn stroke_options(st: &StrokeLayer) -> Value {
|
||||
json!({
|
||||
"cap": name_of(&CAPS, st.cap),
|
||||
@@ -111,6 +112,7 @@ pub fn stroke_options(st: &StrokeLayer) -> Value {
|
||||
"arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" },
|
||||
"profile": WidthProfile::id_of(st.profile.as_ref()),
|
||||
"widthPoints": st.profile.as_ref().map(|p| &p.points),
|
||||
"gradientMode": st.gradient_mode.name(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -938,6 +938,8 @@ mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
#[cfg(test)]
|
||||
mod tests_strokereach;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Gradients within, along or across a stroke: `paint.editGradient {strokeMode}`, the saved mode,
|
||||
//! and the same picture on the canvas, after Outline Stroke and Expand, and in SVG and PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, NodeKind, StrokeGradientMode};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
/// A selected open path (an L: right, then down) with a 20 pt white → black stroke laid `mode`.
|
||||
fn setup(mode: &str) -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 20, "y": 40}, {"x": 160, "y": 40}, {"x": 160, "y": 180}]);
|
||||
let id = run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||||
run(&mut s, "stroke.set", json!({"weight": 20}));
|
||||
run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": mode}));
|
||||
(s, NodeId(id))
|
||||
}
|
||||
|
||||
fn mode_of(s: &Session, id: NodeId) -> StrokeGradientMode {
|
||||
doc(s).node(id).unwrap().appearance.stroke().unwrap().gradient_mode
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
||||
}
|
||||
|
||||
fn grey(img: &Rendered, x: u32, y: u32) -> f64 {
|
||||
img.pixel(x, y)[0] as f64 / 255.0
|
||||
}
|
||||
|
||||
/// Points inside the stroke (away from its edges) where two pictures are compared.
|
||||
const PROBES: [(u32, u32); 8] = [(30, 40), (60, 35), (100, 44), (150, 40), (163, 37), (160, 80), (156, 130), (164, 170)];
|
||||
|
||||
/// `b` paints the stroke as `a` does, within `tol` at every probe.
|
||||
fn assert_same(a: &Rendered, b: &Rendered, tol: f64, what: &str) {
|
||||
for (x, y) in PROBES {
|
||||
let (ga, gb) = (grey(a, x, y), grey(b, x, y));
|
||||
assert!((ga - gb).abs() <= tol, "{what} at ({x}, {y}): {gb} vs {ga}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_mode_sets_how_the_gradient_lies_with_one_undo_step() {
|
||||
let (mut s, id) = setup("along");
|
||||
assert_eq!(mode_of(&s, id), StrokeGradientMode::Along);
|
||||
assert!(matches!(doc(&s).node(id).unwrap().appearance.stroke_paint(), Paint::Gradient(_)), "a solid stroke became a gradient");
|
||||
let o = &run(&mut s, "document.inspect", json!({}))["layers"][0]["children"][0]["strokeOptions"];
|
||||
assert_eq!(o["gradientMode"], "along");
|
||||
run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "across"}));
|
||||
assert_eq!(mode_of(&s, id), StrokeGradientMode::Across);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(mode_of(&s, id), StrokeGradientMode::Along);
|
||||
// Bad values and fills are refused without touching the document.
|
||||
assert!(s.execute("paint.editGradient", &json!({"stroke": true, "strokeMode": "sideways"})).is_err());
|
||||
assert!(s.execute("paint.editGradient", &json!({"stroke": false, "strokeMode": "along"})).is_err());
|
||||
assert_eq!(mode_of(&s, id), StrokeGradientMode::Along);
|
||||
// With nothing selected it sets up the next object drawn.
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "across"}));
|
||||
let r = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap();
|
||||
assert_eq!(mode_of(&s, NodeId(r)), StrokeGradientMode::Across);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_round_trips_through_the_native_format_and_old_files_paint_within() {
|
||||
let (s, id) = setup("across");
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save_file(doc(&s))).unwrap();
|
||||
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap().gradient_mode, StrokeGradientMode::Across);
|
||||
let (s, id) = setup("within");
|
||||
let json = serde_json::to_string(doc(&s).node(id).unwrap().appearance.stroke().unwrap()).unwrap();
|
||||
assert!(!json.contains("gradient_mode"), "the default is not written: {json}");
|
||||
let old: vectorcraft_doc::StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
|
||||
assert_eq!(old.gradient_mode, StrokeGradientMode::Within);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn along_and_across_paint_the_canvas_by_position_on_the_path() {
|
||||
// The path is 280 long: (90, 40) is a quarter of the way, (160, 110) three quarters.
|
||||
let (s, _) = setup("along");
|
||||
let img = render(doc(&s));
|
||||
for (x, y, t) in [(90, 40, 0.25), (90, 47, 0.25), (160, 110, 0.75), (154, 110, 0.75), (166, 34, 0.5)] {
|
||||
assert!((grey(&img, x, y) - (1.0 - t)).abs() < 0.03, "along ({x}, {y}): {}", grey(&img, x, y));
|
||||
}
|
||||
// Across: travelling right the left edge is the top one; going down, the right-hand one.
|
||||
let (s, _) = setup("across");
|
||||
let img = render(doc(&s));
|
||||
assert!(grey(&img, 90, 32) > 0.8 && grey(&img, 90, 48) < 0.2);
|
||||
assert!(grey(&img, 168, 120) > 0.8 && grey(&img, 152, 120) < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_and_expand_keep_the_picture() {
|
||||
for mode in ["along", "across"] {
|
||||
let (mut s, _) = setup(mode);
|
||||
let before = render(doc(&s));
|
||||
let ids = run(&mut s, "object.path.outlineStroke", json!({}))["ids"].clone();
|
||||
let n = doc(&s).node(NodeId(ids[0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Group { children, clip: true } = &n.kind else { panic!("a clip group: {:?}", n.kind_label()) };
|
||||
assert!(children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes");
|
||||
assert_same(&before, &render(doc(&s)), 0.06, &format!("{mode} outlined"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
assert_same(&before, &render(doc(&s)), 0.06, &format!("{mode} expanded"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() {
|
||||
for mode in ["along", "across"] {
|
||||
let (s, _) = setup(mode);
|
||||
let before = render(doc(&s));
|
||||
let (svg, warnings) = vectorcraft_svg::export_with_report(doc(&s), &Default::default());
|
||||
assert!(warnings.iter().any(|w| w.contains("along or across strokes")), "{warnings:?}");
|
||||
assert!(svg.contains("<clipPath") && svg.matches("<linearGradient").count() > 3, "{mode}: sliced");
|
||||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||||
assert_same(&before, &render(&back), 0.04, &format!("{mode} SVG"));
|
||||
let opts = vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() };
|
||||
let r = vectorcraft_pdf::export_with_report(doc(&s), &opts).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("along or across strokes")), "{:?}", r.warnings);
|
||||
assert!(String::from_utf8_lossy(&r.bytes).matches("/ShadingType 2").count() > 3, "{mode}: axial shadings");
|
||||
let back = vectorcraft_pdf::import(&r.bytes).unwrap();
|
||||
assert_same(&before, &render(&back), 0.04, &format!("{mode} PDF"));
|
||||
}
|
||||
// Within, a stroke is still written as one plain stroke.
|
||||
let (s, _) = setup("within");
|
||||
let (svg, warnings) = vectorcraft_svg::export_with_report(doc(&s), &Default::default());
|
||||
assert!(warnings.is_empty() && !svg.contains("<clipPath"));
|
||||
}
|
||||
Reference in New Issue
Block a user