Files
vectorcraft/crates/render/src/tests_freeform.rs
T
bflatasticandClaude Opus 5.5 2877407111 M3.81: freeform gradients: colour points with spread, smooth lines, first points placed inside the shape
Freeform gradients carry their own points (colour, opacity, spread) and lines
(Catmull-Rom curves through points), blended by inverse-distance weighting
from each disc of pure colour. paint.editGradient {kind: "freeform", mode?}
places 4+ points inside the shape, coloured along the stops (which follow the
points' colours). The renderer samples the field on a cached colour grid
drawn as an image paint clipped by the fill or stroke. Moves, transforms,
box rebases and warps move the points; old files load unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:21:38 +07:00

97 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Freeform gradients: the rendered colour grid against the colour field.
use super::*;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::{Point, shapes};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Freeform {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
Freeform {
points: vec![
p(20.0, 20.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(80.0, 80.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(80.0, 20.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
}
}
fn paint(f: Option<Freeform>) -> Paint {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = f;
Paint::Gradient(Box::new(g))
}
/// A 100 × 100 document with `shape` (on 0..100) painted `fill`, at `opacity`.
fn doc(shape: vectorcraft_geom::PathData, fill: Paint, opacity: f32) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shape, Appearance::basic(fill, Paint::None, 0.0));
n.opacity = opacity;
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The field's colour at `p` composited over white (8-bit).
fn expected(f: &Freeform, p: Point, opacity: f32) -> [u8; 4] {
let ([r, g, b], a) = f.field(spread_scale(Rect::new(0.0, 0.0, 100.0, 100.0))).sample(p);
let a = a * opacity;
let q = |v: f32| ((v * a + (1.0 - a)) * 255.0).round() as u8;
[q(r), q(g), q(b), 255]
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 1.0));
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the red point's colour");
assert_eq!(r.pixel(80, 80), [0, 0, 255, 255], "the blue point's colour");
for (x, y) in [(50, 50), (35, 60), (65, 30), (10, 90), (95, 5), (50, 20)] {
let want = expected(&f, Point::new(x as f64 + 0.5, y as f64 + 0.5), 1.0);
assert!(near(r.pixel(x, y), want, 6), "({x}, {y}): {:?} vs {want:?}", r.pixel(x, y));
}
}
#[test]
fn the_grid_is_clipped_to_the_path_and_takes_the_object_opacity() {
let f = freeform();
let r = render(&doc(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 0.5));
assert_eq!(r.pixel(2, 2), [255, 255, 255, 255], "outside the ellipse");
let want = expected(&f, Point::new(50.5, 50.5), 0.5);
assert!(near(r.pixel(50, 50), want, 6), "{:?} vs {want:?}", r.pixel(50, 50));
}
#[test]
fn unplaced_freeform_gradients_seed_points_on_the_box() {
// Without points: the automatic ones, coloured white → black along the default stops.
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(None), 1.0));
let auto = Freeform::auto(Rect::new(0.0, 0.0, 100.0, 100.0), &Gradient::default(), &|_| true);
let at = |i: usize| auto.points[i].at;
// (Pixel centres sit half a pixel off the points.)
assert!(near(r.pixel(at(0).x as u32, at(0).y as u32), [255, 255, 255, 255], 3));
assert!(near(r.pixel(at(3).x as u32, at(3).y as u32), [0, 0, 0, 255], 3));
}
#[test]
fn strokes_take_freeform_paint() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let line = shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0));
let n = Node::path(id, line, Appearance::basic(Paint::None, paint(Some(freeform())), 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the stroke's corner sits on the red point");
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255], "unfilled inside");
}