mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 21:14:51 +00:00
Hollow heads draw as rings (the open arrow as a chevron) and closed paths get
no heads. The stroke stops under each head, so it never shows through a hollow
head or past the tip; stroke.set {arrowAlign: "extend"|"tip"} chooses whether
the tip extends past the end point (default) or sits on it with the stroke
shortened. Line and heads share one paint box (a gradient runs on into the
head) and take the stroke and object opacity once, as one layer on the canvas
and one group in PDF. StrokeLayer.arrow_align is a new defaulted field, so
older files load unchanged. The Stroke panel's Align buttons get two generated
icons and are disabled when the stroke has no head.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
14 KiB
Rust
326 lines
14 KiB
Rust
use super::*;
|
|
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
|
use vectorcraft_doc::{Appearance, Node};
|
|
use vectorcraft_geom::shapes;
|
|
|
|
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
|
|
let id = d.alloc_id();
|
|
Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w))
|
|
}
|
|
|
|
fn render(d: &Document) -> Rendered {
|
|
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
|
}
|
|
|
|
#[test]
|
|
fn fills_red_rect() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
|
let d = {
|
|
let mut d2 = d.clone();
|
|
d2.insert(Some(d.layers[0].id), 0, n).unwrap();
|
|
d2
|
|
};
|
|
let r = render(&d);
|
|
assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]);
|
|
assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn stroke_draws_outline_only() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = render(&d);
|
|
assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]);
|
|
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn inside_stroke_stays_inside() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0);
|
|
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside;
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = render(&d);
|
|
assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]);
|
|
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn outside_stroke_stays_outside() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0);
|
|
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside;
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = render(&d);
|
|
assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]);
|
|
assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_blends() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
n.opacity = 0.5;
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let p = render(&d).pixel(50, 50);
|
|
assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn multiply_blend() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0);
|
|
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0);
|
|
b.blend = vectorcraft_color::BlendMode::Multiply;
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, a).unwrap();
|
|
d.insert(Some(l), 1, b).unwrap();
|
|
assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn linear_gradient_goes_white_to_black() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n =
|
|
rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = render(&d);
|
|
assert!(r.pixel(2, 50)[0] > 240);
|
|
assert!(r.pixel(97, 50)[0] < 15);
|
|
let m = r.pixel(50, 50)[0] as i32;
|
|
assert!((m - 128).abs() < 12, "{m}");
|
|
}
|
|
|
|
#[test]
|
|
fn clip_group_clips() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0);
|
|
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
|
*clipping = true;
|
|
}
|
|
let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
let gid = d.alloc_id();
|
|
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, g).unwrap();
|
|
let r = render(&d);
|
|
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
|
|
assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_and_culled() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
n.visible = false;
|
|
let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
d.insert(Some(l), 1, far).unwrap();
|
|
let mut rr = Renderer::new();
|
|
let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
|
assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]);
|
|
assert_eq!(rr.stats.culled, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn view_transform_zooms() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default());
|
|
assert_eq!(r.pixel(45, 45)[3], 255);
|
|
assert_eq!(r.pixel(55, 55)[3], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn outline_mode_no_fill() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r =
|
|
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() });
|
|
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
|
|
assert!(r.pixel(10, 50)[0] < 200);
|
|
}
|
|
|
|
#[test]
|
|
fn artboards_white_on_pasteboard() {
|
|
let d = Document::new(50.0, 50.0);
|
|
let r = Renderer::new().render(
|
|
&d,
|
|
100,
|
|
100,
|
|
Affine::IDENTITY,
|
|
&RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() },
|
|
);
|
|
assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]);
|
|
assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn region_export_png() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true);
|
|
assert_eq!((r.width, r.height), (200, 200));
|
|
let png = r.to_png();
|
|
assert_eq!(&png[1..4], b"PNG");
|
|
}
|
|
|
|
#[test]
|
|
fn arrowheads_render() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let id = d.alloc_id();
|
|
let mut n =
|
|
Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
|
|
n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let r = render(&d);
|
|
// The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line.
|
|
assert!(r.pixel(78, 48)[0] < 128);
|
|
assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip");
|
|
}
|
|
|
|
use vectorcraft_doc::Arrowhead;
|
|
use vectorcraft_geom::Point;
|
|
|
|
/// A red 10..90 square masked by a white square covering only its left half (10..50),
|
|
/// plus a mid-grey strip (50..70) — rendered both multi- and single-threaded.
|
|
fn masked_doc(clip: bool, invert: bool) -> Document {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
|
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
|
let grey = rect_node(&mut d, Rect::new(50.0, 10.0, 70.0, 90.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
|
|
let gid = d.alloc_id();
|
|
let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]);
|
|
let mut m = vectorcraft_doc::OpacityMask::new(art, clip);
|
|
m.invert = invert;
|
|
n.mask = Some(Box::new(m));
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_mask_uses_luminance_and_clips() {
|
|
for threads in [0, 2] {
|
|
let mut r = Renderer::new();
|
|
r.threads = threads;
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
|
let img = r.render(&masked_doc(true, false), 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "white mask → opaque ({threads} threads)");
|
|
let mid = img.pixel(60, 50);
|
|
assert!((100..160).contains(&mid[1]), "grey mask → about half opacity, got {mid:?}");
|
|
assert_eq!(img.pixel(80, 50), [255, 255, 255, 255], "clip hides outside the mask art");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_mask_without_clip_and_inverted() {
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
|
let img = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "no clip: outside the mask art stays visible");
|
|
let img = Renderer::new().render(&masked_doc(true, true), 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(img.pixel(30, 50), [255, 255, 255, 255], "inverted white → hidden");
|
|
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_or_outline_mask_draws_unmasked() {
|
|
let mut d = masked_doc(true, false);
|
|
let id = d.layers[0].children().unwrap()[0].id;
|
|
let n = d.node_mut(id).unwrap();
|
|
n.mask.as_mut().unwrap().disabled = true;
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
|
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn linked_mask_moves_with_object() {
|
|
let mut d = masked_doc(true, false);
|
|
let id = d.layers[0].children().unwrap()[0].id;
|
|
d.node_mut(id).unwrap().transform(Affine::translate((5.0, 0.0)), false);
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
|
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(img.pixel(52, 50), [255, 0, 0, 255], "mask art moved by 5 too");
|
|
}
|
|
|
|
#[test]
|
|
fn trim_view_clips_to_artboards() {
|
|
let mut d = Document::new(50.0, 50.0);
|
|
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), trim: true, ..Default::default() };
|
|
let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(r.pixel(25, 25), [255, 0, 0, 255]);
|
|
assert_eq!(r.pixel(75, 75), [255, 255, 255, 255]);
|
|
assert_eq!(render(&d).pixel(75, 75), [255, 0, 0, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn drop_shadow_profile_and_cache() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 60.0, 60.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
|
n.appearance.effects.push(vectorcraft_doc::Effect {
|
|
id: "stylize.dropShadow".into(),
|
|
params: serde_json::json!({"x": 10.0, "y": 10.0, "blur": 4.0, "opacity": 75.0}),
|
|
visible: true,
|
|
});
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
|
for threads in [0, 4] {
|
|
let mut r = Renderer::new();
|
|
r.threads = threads;
|
|
let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
|
// Inside the shadow (not under the object): 75% black. At its blurred edge: about half.
|
|
let inside = a.pixel(65, 50)[0] as i32;
|
|
assert!((inside - 64).abs() <= 6, "inside {inside}");
|
|
let edge = a.pixel(70, 50)[0] as i32;
|
|
assert!((edge - 160).abs() <= 20, "edge {edge}");
|
|
assert_eq!(a.pixel(85, 50), [255, 255, 255, 255]);
|
|
// Second frame comes from the cache: identical. A pan moves it rigidly.
|
|
let b = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
|
assert_eq!(a.pixels, b.pixels);
|
|
let c = r.render(&d, 100, 100, Affine::translate((-5.0, 0.0)), &opts);
|
|
assert_eq!(c.pixel(60, 50), a.pixel(65, 50));
|
|
assert_eq!(c.pixel(65, 50), a.pixel(70, 50));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn blend_mode_on_a_single_fill_is_exact_without_a_layer() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let red = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
|
let mut over = rect_node(&mut d, Rect::new(25.0, 0.0, 75.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 1.0)), Paint::None, 0.0);
|
|
over.blend = vectorcraft_color::BlendMode::Multiply;
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, red).unwrap();
|
|
d.insert(Some(l), 1, over).unwrap();
|
|
for threads in [0, 4] {
|
|
let mut r = Renderer::new();
|
|
r.threads = threads;
|
|
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
|
|
let [rr, g, b, _] = img.pixel(35, 50);
|
|
assert!((rr as i32 - 128).abs() <= 2 && g <= 1 && b <= 1, "multiply over red: {:?}", img.pixel(35, 50));
|
|
let [rr, g, b, _] = img.pixel(65, 50);
|
|
assert!((rr as i32 - 128).abs() <= 2 && (g as i32 - 128).abs() <= 2 && b >= 253, "multiply over white");
|
|
assert_eq!(img.pixel(10, 50), [255, 0, 0, 255]);
|
|
}
|
|
}
|