Files
vectorcraft/crates/engine/src/tests_strokegradient.rs
T
bflatasticandClaude Opus 5.5 025d758abe 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>
2026-10-05 05:47:54 +07:00

148 lines
7.2 KiB
Rust

//! 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"));
}