mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 18:15:49 +00:00
A new gradient kind, `diamond`, runs its stops from the center (the start) out to a square with a corner at the end of the vector; the aspect stretches the square across the vector into a rhombus. The Gradient panel's Type row has a Diamond button, the Gradient tool draws the square with the aspect handle and center ring, and a gradient fitted to an object covers its box. - Model and canvas: the level is |x| + |y| in the square's frame; a square with no area (no vector, an aspect of 0, numbers out of range) paints the last stop. The canvas draws it as pixels, out to a stroke's miters, caps and arrowheads. Transforms keep the vector and take the mapped size across it as the aspect. - Commands: `paint.setFill`, `paint.editGradient` and `paint.setGradientGeom` take `kind: "diamond"`; a negative aspect or a focal point is an error. A conic or diamond gradient that a plug-in returns with a focal point loses the focal point. - Gradient tool: dragging the aspect handle of a radial or diamond gradient whose file gave it a negative aspect now sets a positive aspect; only a conic's negative aspect mirrors it. - Export: PDF, SVG, EPS and legacy .ai write four linear gradients, one for each quarter of the square, clipped to the shape (PDF as axial shadings). PDF and SVG write an image for translucent stops, a dithered fill or a flat square, and for the Perceptual method unless PDF writes the stops as spot color shadings. SVG writes a diamond on type characters as a radial gradient through the corners, and so does CSS. A swatch library saved as CSS gets four corner layers, a value for the `background` property (a circle when the swatch is turned by an angle that is not a multiple of 90 degrees). The .ai editing data holds a radial gradient through the corners. The document exports give a warning. - SVG export: a stroke drawn as an image, as conic wedges or as diamond quarters now covers the miters, caps and arrowheads that reach past its paint box. - PDF import reads a function-based shading whose color is the same along each square about the origin of its space as a diamond. - Object > Expand gives nested squares or a mesh of rings. Docs: docs/mcp.md, docs/target-app-parity.md.
118 lines
5.8 KiB
Rust
118 lines
5.8 KiB
Rust
//! Diamond gradients: the pixels run from the center out to a square with a corner at the end of
|
|
//! the vector, and follow the model's parameter when the square is stretched or turned, on fills
|
|
//! and strokes; a stroke gets no dither noise, and a diamond without an extent draws its last stop.
|
|
|
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
|
use vectorcraft_doc::{Appearance, Document};
|
|
use vectorcraft_geom::{Point, Rect, Vec2, shapes};
|
|
use vectorcraft_testkit::fixtures::DocBuilder;
|
|
use vectorcraft_testkit::raster::{Image, render_view};
|
|
|
|
/// A white-to-black diamond on `geom`, dithered when `dither`.
|
|
fn paint(geom: GradientGeom, dither: bool) -> Paint {
|
|
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, dither, ..Gradient::default() });
|
|
gp.geom = Some(geom);
|
|
Paint::Gradient(Box::new(gp))
|
|
}
|
|
|
|
/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`.
|
|
fn square(fill: Paint, stroke: Paint, width: f64) -> Document {
|
|
let mut b = DocBuilder::new(100.0, 100.0);
|
|
b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {});
|
|
b.build()
|
|
}
|
|
|
|
/// Every 7th pixel inside `inside` has the white-to-black ramp's gray at the model's parameter
|
|
/// there (pixel centers).
|
|
fn assert_follows_model(img: &Image, geom: &GradientGeom, inside: Rect) {
|
|
for y in (3..100).step_by(7) {
|
|
for x in (3..100).step_by(7) {
|
|
let p = Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
|
if !inside.contains(p) {
|
|
continue;
|
|
}
|
|
let t = geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0);
|
|
let want = ((1.0 - t) * 255.0).round() as u8;
|
|
let got = img.over_white(x, y)[0];
|
|
assert!(got.abs_diff(want) <= 6, "({x},{y}) t={t:.3}: got {got}, want {want}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_diamond_fill_follows_the_squares() {
|
|
let c = Point::new(50.0, 50.0);
|
|
let level = GradientGeom { start: c, end: c + Vec2::new(40.0, 0.0), aspect: 1.0, focal: None };
|
|
let stretched = GradientGeom { aspect: 0.5, ..level };
|
|
let turned = GradientGeom { end: c + Vec2::new(30.0, 30.0), aspect: 0.7, ..level };
|
|
for geom in [level, stretched, turned] {
|
|
let img = render_view(&square(paint(geom, false), Paint::None, 0.0), 100, 100);
|
|
assert_follows_model(&img, &geom, Rect::new(12.0, 12.0, 88.0, 88.0));
|
|
}
|
|
// Level: white at the center, mid gray halfway to a corner of the square, along an axis and
|
|
// along a diagonal alike (pixel centers 20 pt from the center, counted along the axes).
|
|
let img = render_view(&square(paint(level, false), Paint::None, 0.0), 100, 100);
|
|
assert!(img.over_white(50, 50)[0] > 240, "{:?}", img.over_white(50, 50));
|
|
for (x, y) in [(69, 49), (49, 69), (60, 59), (39, 40)] {
|
|
assert!(img.over_white(x, y)[0].abs_diff(128) <= 6, "({x},{y}): {:?}", img.over_white(x, y));
|
|
}
|
|
}
|
|
|
|
/// A stroke follows the squares too, without the noise dither puts on fills.
|
|
#[test]
|
|
fn a_diamond_stroke_follows_the_squares_without_dither() {
|
|
let c = Point::new(50.0, 50.0);
|
|
let geom = GradientGeom { start: c, end: c + Vec2::new(60.0, 0.0), aspect: 1.0, focal: None };
|
|
let doc = square(Paint::None, paint(geom, true), 6.0);
|
|
let img = render_view(&doc, 100, 100);
|
|
// Along the top edge (y 10, inside the stroke's inner half), the gray follows the model and
|
|
// two pixels the same distance from the center are the same.
|
|
for x in [20, 35, 50, 65, 80] {
|
|
let p = Point::new(x as f64 + 0.5, 10.5);
|
|
let want = ((1.0 - geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0)) * 255.0).round() as u8;
|
|
assert!(img.over_white(x, 10)[0].abs_diff(want) <= 6, "x {x}: {:?} vs {want}", img.over_white(x, 10));
|
|
}
|
|
assert_eq!(img.over_white(30, 10), img.over_white(69, 10));
|
|
}
|
|
|
|
/// A stroke's miter reaches past its paint box (the path's box grown by half the weight): the
|
|
/// diamond's grays run on there, as the model gives them, instead of repeating the box's edge.
|
|
#[test]
|
|
fn a_diamond_stroke_follows_the_squares_past_its_paint_box() {
|
|
let c = Point::new(50.0, 50.0);
|
|
let geom = GradientGeom { start: c, end: c + Vec2::new(45.0, 0.0), aspect: 1.0, focal: None };
|
|
let mut ap = Appearance::basic(Paint::None, paint(geom, false), 8.0);
|
|
for it in &mut ap.items {
|
|
if let vectorcraft_doc::AppearanceItem::Stroke(s) = it {
|
|
s.miter_limit = 10.0;
|
|
}
|
|
}
|
|
let mut b = DocBuilder::new(100.0, 100.0);
|
|
let peak = vectorcraft_geom::SubPath::polyline(&[Point::new(20.0, 80.0), Point::new(50.0, 20.0), Point::new(80.0, 80.0)], false);
|
|
b.path(vectorcraft_geom::PathData::single(peak), ap, |_| {});
|
|
let img = render_view(&b.build(), 100, 100);
|
|
// The miter's tip runs up to y 11.06; the paint box starts at y 16. Rows 13 and 14 are
|
|
// covered across pixel column 50.
|
|
for y in [13, 14] {
|
|
let p = Point::new(50.5, y as f64 + 0.5);
|
|
let want = ((1.0 - geom.param_at(GradientKind::Diamond, p).clamp(0.0, 1.0)) * 255.0).round() as u8;
|
|
assert!(img.over_white(50, y)[0].abs_diff(want) <= 6, "y {y}: {:?} vs {want}", img.over_white(50, y));
|
|
}
|
|
}
|
|
|
|
/// Without an extent (no vector, or a flat square) every point is past the square: the last stop.
|
|
#[test]
|
|
fn a_diamond_without_an_extent_draws_its_last_stop() {
|
|
let c = Point::new(50.0, 50.0);
|
|
for geom in [
|
|
GradientGeom { start: c, end: c, aspect: 1.0, focal: None },
|
|
GradientGeom { start: c, end: c + Vec2::new(30.0, 0.0), aspect: 0.0, focal: None },
|
|
GradientGeom { start: c, end: c + Vec2::new(f64::INFINITY, 0.0), aspect: 1.0, focal: None },
|
|
] {
|
|
let img = render_view(&square(paint(geom, false), Paint::None, 0.0), 100, 100);
|
|
for (x, y) in [(50, 50), (20, 80), (85, 15)] {
|
|
assert!(img.over_white(x, y)[0] < 6, "{geom:?} ({x},{y}): {:?}", img.over_white(x, y));
|
|
}
|
|
}
|
|
}
|