mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Every symbol instance was measured as its transform applied to the 20 × 20 square around the origin, which symbols made in the app keep their art in. The SVG and DXF importers keep symbol art as drawn, so the box of an instance of an imported symbol did not match its art. The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50) and was measured from (10, 20) to (30, 40). Align moved such an instance by the wrong amount, clicks and marquees beside its art selected it, and the canvas, exports and prints left it out when its art reached the view or the page and the square did not. An instance is now measured by its symbol's art, which Document::symbol_boxes looks up in the document each time (Node::geometric_bounds_with, visual_bounds_with and reach_bounds_with). With Use Preview Bounds on, the strokes in the art count at their own weight, as the canvas draws them, and object.setBounds counts them at that weight when it sizes an instance or an object that holds one. When strokes do not scale and a width or height of 0 or more is smaller than what a path's stroke adds, object.setBounds now gives the path no width or height, as it does for an instance whose art has strokes; before, it mirrored the path. With Use Preview Bounds on, object.setBounds now measures the margins that strokes add object by object, the members of groups and the objects in symbol instances' art included, and solves for the width, height and reference point asked for when the width and height are 0 or more. Before, it measured the margins of the whole selection and could miss the size or the reference point asked for when strokes did not scale and the objects' strokes added different margins, and when strokes scaled and the width and height were out of proportion, even for one path. A clip group, a Shaper Group, a compound path, type, and a group or instance with effects or an opacity mask of its own are each measured as one object, and so is an instance in perspective or one turned or slanted relative to the bounding box, other than by quarter turns. When the selection holds more than 4,096 objects to measure this way, each selected object is measured as one. Instances inside symbol art are measured by their own symbol's art. An instance is measured as the square when its symbol is missing, when its symbol or a symbol nested in its art holds an instance of itself, and when symbols nest 32 deep in its art. Align and Distribute, the Transform panel, the bounding box and the selection outline, clicks, the marquee, document.inspect and document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas culling, thumbnails, the SVG filter region of an instance with a raster effect, raster effect images, Export Selection, the SVG of an artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the new box. The renderer also culls an instance by the effects and brushed strokes in its art, and it culls a blend, envelope or repeat by the brush art of what it holds and the effects in the art of the instances in it. It culls an instance with geometry effects of its own by the effects in its art after those geometry effects reshape the art. It measures the effects in a symbol's art once for each symbol and linear part of the transform that places it. For symbols whose art holds instances, it measures at most 4,096 placements a frame, and at most 4,096 more for each pattern tile it rasterizes and each placed document it draws. Past that, it estimates the reach from the symbol's reach with no transform, which it measures once. When the drawn document's symbols change, the renderer drops the cached bounds of the instances and of the objects that hold them. The PDF and EPS writers, the SVG of an artboard and Skip Blank Artboards grow the box of an instance whose art holds brushed strokes by 16 times the widest stroke (at least 1 pt) of the objects in that art that have a brushed stroke, plus 8 pt, as the renderer grows the cull bounds of a brushed path. They keep an instance whose brush art reaches the page. The SVG of an artboard also keeps an object that holds an instance when what the canvas paints for it, the effects in the symbol's art and the instance's own included, reaches the artboard. Not yet: other commands that measure objects, such as Select › All on Active Artboard, Transform Each and Convert to Artboards, still measure an instance by the square. Docs: docs/mcp.md, docs/target-app-parity.md.
875 lines
45 KiB
Rust
875 lines
45 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 raster_size_limits() {
|
||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||
assert_eq!(raster_size(r, 2.0), Ok((200, 100)));
|
||
assert_eq!(raster_size(r, 0.001), Ok((1, 1)));
|
||
assert_eq!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64, 1.0), 1.0), Ok((MAX_RASTER_SIDE, 1)));
|
||
assert!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64 + 1.0, 1.0), 1.0).is_err());
|
||
assert!(raster_size(Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 1.0).is_err(), "400 megapixels");
|
||
assert!(raster_size(r, f64::NAN).is_err());
|
||
assert!(raster_size(r, f64::INFINITY).is_err());
|
||
assert!(raster_size(r, 0.0).is_err());
|
||
}
|
||
|
||
#[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]);
|
||
}
|
||
|
||
/// #1396: a stroke wider than its ellipse is a solid blob, pixel for pixel the disc a renderer
|
||
/// with a native stroke (the PDF and SVG exports) draws, at every alignment and zoom.
|
||
#[test]
|
||
fn a_stroke_wider_than_its_ellipse_paints_solid() {
|
||
let doc = |r: f64, fill: Paint, stroke: Paint, w: f64, align: StrokeAlign| {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, shapes::ellipse(Rect::new(50.0 - r, 50.0 - r, 50.0 + r, 50.0 + r)), Appearance::basic(fill, stroke, w));
|
||
if let Some(st) = n.appearance.stroke_mut() {
|
||
st.align = align;
|
||
}
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
};
|
||
let black = || Paint::solid(Color::BLACK);
|
||
let render = |d: &Document, zoom: f64| {
|
||
let view = Affine::translate((50.0, 50.0)) * Affine::scale(zoom) * Affine::translate((-50.0, -50.0));
|
||
Renderer::new().render(d, 100, 100, view, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||
};
|
||
// (ellipse radius, weight, alignment, radius of the disc it paints)
|
||
for (r, w, align, disc) in [
|
||
(10.0, 60.0, StrokeAlign::Center, 40.0),
|
||
(6.0, 50.0, StrokeAlign::Center, 31.0),
|
||
(15.0, 50.0, StrokeAlign::Center, 40.0),
|
||
(10.0, 30.0, StrokeAlign::Outside, 40.0),
|
||
(10.0, 30.0, StrokeAlign::Inside, 10.0),
|
||
] {
|
||
for zoom in [0.5, 1.0, 2.0] {
|
||
let got = render(&doc(r, Paint::None, black(), w, align), zoom);
|
||
if align == StrokeAlign::Outside {
|
||
// The shape itself stays unpainted, and the stroke around it has no gap.
|
||
assert_eq!(got.pixel(50, 50), [255, 255, 255, 255], "{r} {w} {align:?} at {zoom}");
|
||
assert_eq!(got.pixel(50 + (12.0 * zoom) as u32, 50), [0, 0, 0, 255], "{r} {w} {align:?} at {zoom}");
|
||
continue;
|
||
}
|
||
let want = render(&doc(disc, black(), Paint::None, 0.0, StrokeAlign::Center), zoom);
|
||
let off =
|
||
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| got.pixel(x, y)[0].abs_diff(want.pixel(x, y)[0]) > 40).count();
|
||
assert!(off <= 8, "{r} {w} {align:?} at {zoom}: {off} pixels differ from the disc");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// A document with symbol "Far" holding a red rectangle `art`, and an instance of it moved by
|
||
/// (-50, -50): its 20 × 20 square, (-60, -60) to (-40, -40), lies outside a view of the page.
|
||
fn far_instance(art: Rect) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let art = rect_node(&mut d, art, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
|
||
let (id, l) = (d.alloc_id(), d.layers[0].id);
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_is_culled_by_its_symbols_art() {
|
||
// The art drawn from (350, 350) is out of view.
|
||
let away = far_instance(Rect::new(400.0, 400.0, 440.0, 440.0));
|
||
let mut rr = Renderer::new();
|
||
assert_eq!(rr.render(&away, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [0, 0, 0, 0]);
|
||
assert_eq!(rr.stats.culled, 1);
|
||
// The symbol's art, from (100, 100), drawn from (50, 50): in view. The instance is the same
|
||
// node, and the renderer measures it again.
|
||
let mut near = away.clone();
|
||
let art = rect_node(&mut near, Rect::new(100.0, 100.0, 140.0, 140.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||
near.symbols[0].art = Arc::new(art);
|
||
assert_eq!(rr.render(&near, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [255, 0, 0, 255]);
|
||
assert_eq!(rr.stats.culled, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_is_culled_by_the_effects_in_its_art() {
|
||
// The art, (-1, -1) to (1, 1), drawn from (-5, 20) lies left of the view, and its drop shadow,
|
||
// 8 pt to the right, reaches into it.
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
|
||
art.appearance.effects.push(shadow);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
|
||
let (id, l) = (d.alloc_id(), d.layers[0].id);
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn a_repeat_of_an_instance_is_culled_by_the_effects_in_its_art() {
|
||
// The instance of the test above as the one copy of a one-by-one grid repeat.
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
|
||
art.appearance.effects.push(shadow);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
|
||
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
|
||
let grid = vectorcraft_doc::TileType::Grid;
|
||
let kind =
|
||
vectorcraft_doc::RepeatKind::Grid { h_spacing: 0.0, v_spacing: 0.0, rows: 1, cols: 1, grid_type: grid, flip_rows: false, flip_cols: false };
|
||
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(instance)] }));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, repeat).unwrap();
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
|
||
}
|
||
|
||
#[test]
|
||
fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_effects() {
|
||
// Symbol `Spot` is a square from (-1, -1) to (1, 1) with a drop shadow 8 pt to the right, and
|
||
// symbols `Dot` and `Late` each hold an instance of it. 4,100 instances of `Dot`, each turned by
|
||
// another angle, lie far left of the view: one frame measures the effects in the art of at most
|
||
// 4,096 placements of symbols that hold instances. An instance of `Late` at (-5, 20) comes
|
||
// after them: its square lies left of the view, and the shadow reaches into it.
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
|
||
art.appearance.effects.push(shadow);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Spot".into(), art: Arc::new(art) });
|
||
for name in ["Dot", "Late"] {
|
||
let spot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Spot".into(), xf: Affine::IDENTITY });
|
||
let group = Node::group(d.alloc_id(), vec![Arc::new(spot)]);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(group) });
|
||
}
|
||
let l = d.layers[0].id;
|
||
for i in 0..4100 {
|
||
let xf = Affine::translate((-1000.0, 50.0)) * Affine::rotate(f64::from(i) * 1e-4);
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), i as usize, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
|
||
}
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), 4100, Node::new(id, NodeKind::SymbolInstance { symbol: "Late".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
|
||
}
|
||
|
||
/// Symbols 0 to 29 each hold two instances of the next, sheared two ways, so that the 2^30
|
||
/// instances drawn from the first are all placed differently. Symbol 30 holds a square with a drop
|
||
/// shadow. An instance of symbol 0 lies far left of the view. With `effect`, every instance has
|
||
/// that effect of its own. The renderer must cull the instance of symbol 0 within 30 s.
|
||
fn nested_instances_are_culled_in_bounded_time(effect: Option<vectorcraft_doc::Effect>) {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut art = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 2, "y": 0, "blur": 0}), visible: true };
|
||
art.appearance.effects.push(shadow);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "S30".into(), art: Arc::new(art) });
|
||
let instance = |d: &mut Document, symbol: String, xf: Affine| {
|
||
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol, xf });
|
||
n.appearance.effects.extend(effect.clone());
|
||
n
|
||
};
|
||
for i in (0..30).rev() {
|
||
let next = format!("S{}", i + 1);
|
||
let mut pair = vec![];
|
||
for xf in [Affine::new([1.0, 0.0, 0.1, 1.0, 0.0, 0.0]), Affine::new([1.0, 0.1, 0.0, 1.0, 2.0, 0.0])] {
|
||
pair.push(Arc::new(instance(&mut d, next.clone(), xf)));
|
||
}
|
||
let group = Node::group(d.alloc_id(), pair);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: format!("S{i}"), art: Arc::new(group) });
|
||
}
|
||
let first = instance(&mut d, "S0".into(), Affine::translate((-100_000.0, 0.0)));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, first).unwrap();
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
std::thread::spawn(move || {
|
||
let mut r = Renderer::new();
|
||
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
tx.send((img.pixel(50, 50), r.stats.culled)).ok();
|
||
});
|
||
let (pixel, culled) = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the frame within 30 s");
|
||
assert_eq!(pixel, [0, 0, 0, 0]);
|
||
assert!(culled > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_instances_with_effects_are_culled_in_bounded_time() {
|
||
nested_instances_are_culled_in_bounded_time(None);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_instances_with_geometry_effects_of_their_own_are_culled_in_bounded_time() {
|
||
// Each instance's Transform effect reshapes the art it shows before the effects in that art run.
|
||
let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 1}), visible: true };
|
||
nested_instances_are_culled_in_bounded_time(Some(moved));
|
||
}
|
||
|
||
#[test]
|
||
fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() {
|
||
// The art, (-1, -1) to (1, 1), scaled 30 times and centered on (-50, 50): (-80, 20) to
|
||
// (-20, 80), out of view. Its Transform effect doubles the placed art: (-110, -10) to
|
||
// (10, 110).
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||
let double = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 200, "scaleV": 200}), visible: true };
|
||
art.appearance.effects.push(double);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
|
||
let (id, l) = (d.alloc_id(), d.layers[0].id);
|
||
let xf = Affine::translate((-50.0, 50.0)) * Affine::scale(30.0);
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
assert_eq!(img.pixel(5, 50), [255, 0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
|
||
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
|
||
// moves it 150 pt right, into view.
|
||
let mut d = far_instance(Rect::new(0.0, 0.0, 20.0, 20.0));
|
||
let id = d.layers[0].children().unwrap()[0].id;
|
||
let inst = d.node_mut(id).unwrap();
|
||
inst.kind = NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-100.0, 10.0)) };
|
||
let effect = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 150}), visible: true };
|
||
inst.appearance.effects.push(effect);
|
||
assert_eq!(Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(60, 20), [255, 0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_with_a_geometry_effect_is_culled_by_the_effects_in_its_reshaped_art() {
|
||
// The art, (-1, -1) to (1, 1), and the instance at (-11, 20) each have a Transform effect that
|
||
// scales by 4. The instance's effect scales the placed art, (-12, 19) to (-10, 21), to
|
||
// (-15, 16) to (-7, 24), and the art's effect then scales that to (-27, 4) to (5, 36), which
|
||
// reaches into the view.
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let scale = serde_json::json!({"scaleH": 400, "scaleV": 400});
|
||
let effect = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: scale.clone(), visible: true };
|
||
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||
art.appearance.effects.push(effect());
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
|
||
let (id, l) = (d.alloc_id(), d.layers[0].id);
|
||
let mut instance = Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-11.0, 20.0)) });
|
||
instance.appearance.effects.push(effect());
|
||
d.insert(Some(l), 0, instance).unwrap();
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||
assert_eq!(img.pixel(2, 20), [255, 0, 0, 255]);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// A 100 × 100 document with each kind of path Outline view draws, and the ids of its objects by
|
||
/// name: a stroked square, a compound path with a hole, a path and a compound path with a live
|
||
/// effect, a guide, a clip group, an Image Trace object showing its outlines, and a path in a layer
|
||
/// with Preview off. The clip group holds a path reaching past its clipping path, a compound path,
|
||
/// a group of two paths, a nested clip group, a path with a live effect, a hidden path and an
|
||
/// unstroked path a tenth of a point wide.
|
||
fn outline_doc() -> (Document, Vec<(&'static str, NodeId)>) {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut ids = vec![];
|
||
let black = || Paint::solid(Color::BLACK);
|
||
let offset = |by: f64| vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({ "offset": by }), visible: true };
|
||
let compound = |d: &mut Document, outer: Rect, hole: Rect| {
|
||
let children = [outer, hole].map(|r| Arc::new(rect_node(d, r, Paint::None, Paint::None, 0.0))).to_vec();
|
||
let mut n = Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd });
|
||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||
n
|
||
};
|
||
let clipping = |mut n: Node| {
|
||
if let NodeKind::Path { clipping, .. } = &mut n.kind {
|
||
*clipping = true;
|
||
}
|
||
n
|
||
};
|
||
let mut named = |name: &'static str, n: Node| {
|
||
ids.push((name, n.id));
|
||
Arc::new(n)
|
||
};
|
||
let square = named("square", rect_node(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), black(), 2.0));
|
||
let holed = named("holed", compound(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Rect::new(35.0, 10.0, 45.0, 20.0)));
|
||
let mut fx_path = rect_node(&mut d, Rect::new(55.0, 8.0, 70.0, 22.0), black(), Paint::None, 0.0);
|
||
fx_path.appearance.effects.push(offset(2.0));
|
||
let fx_path = named("fx path", fx_path);
|
||
let mut fx_compound = compound(&mut d, Rect::new(75.0, 5.0, 95.0, 25.0), Rect::new(80.0, 10.0, 90.0, 20.0));
|
||
fx_compound.appearance.effects.push(offset(-1.0));
|
||
let fx_compound = named("fx compound", fx_compound);
|
||
let mut guide = Node::path(d.alloc_id(), shapes::line(Point::new(5.0, 30.0), Point::new(95.0, 30.0)), Appearance::default());
|
||
if let NodeKind::Path { guide, .. } = &mut guide.kind {
|
||
*guide = true;
|
||
}
|
||
let guide = named("guide", guide);
|
||
// The clip group.
|
||
let ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(5.0, 35.0, 55.0, 85.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||
let clip_path = named("clipping path", clipping(ellipse));
|
||
let past = named("past the clip", rect_node(&mut d, Rect::new(0.0, 50.0, 60.0, 60.0), Paint::None, black(), 1.0));
|
||
let member_compound = named("member compound", compound(&mut d, Rect::new(10.0, 65.0, 20.0, 75.0), Rect::new(12.0, 67.0, 18.0, 73.0)));
|
||
let pair_rect = named("pair rect", rect_node(&mut d, Rect::new(25.0, 65.0, 30.0, 75.0), black(), Paint::None, 0.0));
|
||
let pair_ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(32.0, 65.0, 40.0, 75.0)), Appearance::basic(black(), Paint::None, 0.0));
|
||
let pair_ellipse = named("pair ellipse", pair_ellipse);
|
||
let pair = named("pair", Node::group(d.alloc_id(), vec![pair_rect, pair_ellipse]));
|
||
let nested_clip = named("nested clipping path", clipping(rect_node(&mut d, Rect::new(42.0, 62.0, 54.0, 80.0), Paint::None, Paint::None, 0.0)));
|
||
let nested_path = named("nested path", rect_node(&mut d, Rect::new(44.0, 64.0, 70.0, 78.0), black(), Paint::None, 0.0));
|
||
let nested = named("nested", Node::new(d.alloc_id(), NodeKind::Group { children: vec![nested_clip, nested_path], clip: true }));
|
||
let mut member_fx = rect_node(&mut d, Rect::new(15.0, 40.0, 30.0, 47.0), black(), Paint::None, 0.0);
|
||
member_fx.appearance.effects.push(offset(1.5));
|
||
let member_fx = named("member fx", member_fx);
|
||
let mut hidden = rect_node(&mut d, Rect::new(20.0, 80.0, 40.0, 84.0), Paint::None, black(), 1.0);
|
||
hidden.visible = false;
|
||
let hidden = named("hidden", hidden);
|
||
let tiny = named("tiny", rect_node(&mut d, Rect::new(48.0, 56.0, 48.1, 56.1), black(), Paint::None, 0.0));
|
||
let members = vec![clip_path, past, member_compound, pair, nested, member_fx, hidden, tiny];
|
||
let clip = named("clip group", Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true }));
|
||
// The Image Trace object: its hidden source, then the traced shapes.
|
||
let mut source = rect_node(&mut d, Rect::new(60.0, 35.0, 95.0, 95.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
|
||
source.visible = false;
|
||
let shape_a =
|
||
named("trace shape a", rect_node(&mut d, Rect::new(62.0, 40.0, 80.0, 60.0), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), Paint::None, 0.0));
|
||
let shape_b = Node::path(
|
||
d.alloc_id(),
|
||
shapes::ellipse(Rect::new(70.0, 62.0, 92.0, 84.0)),
|
||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0),
|
||
);
|
||
let shape_b = named("trace shape b", shape_b);
|
||
let mut traced = Node::group(d.alloc_id(), vec![Arc::new(source), shape_a, shape_b]);
|
||
traced.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "outlines" })));
|
||
let traced = named("traced", traced);
|
||
let unpreviewed =
|
||
named("unpreviewed", rect_node(&mut d, Rect::new(5.0, 88.0, 40.0, 97.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||
let l = d.layers[0].id;
|
||
for n in [square, holed, fx_path, fx_compound, guide, clip, traced] {
|
||
d.insert(Some(l), usize::MAX, Arc::unwrap_or_clone(n)).unwrap();
|
||
}
|
||
let layer = Node::layer(d.alloc_id(), "Preview off", vectorcraft_doc::LayerColor::Preset(1));
|
||
let off = d.insert(None, usize::MAX, layer).unwrap();
|
||
if let Some(NodeKind::Layer { preview, .. }) = d.node_mut(off).map(|n| &mut n.kind) {
|
||
*preview = false;
|
||
}
|
||
d.insert(Some(off), 0, Arc::unwrap_or_clone(unpreviewed)).unwrap();
|
||
(d, ids)
|
||
}
|
||
|
||
/// The id `outline_doc` gave the object `name`.
|
||
fn id_of(ids: &[(&'static str, NodeId)], name: &str) -> NodeId {
|
||
ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id).unwrap_or_else(|| panic!("no object {name}"))
|
||
}
|
||
|
||
/// Outline frames drawn from the geometry a renderer keeps between frames are the frames a new
|
||
/// renderer draws, at three views each drawn twice, and after a member of the clip group moves
|
||
/// (here with dotted guides). In Preview the Image Trace object's outlines and the layer with
|
||
/// Preview off are drawn in outline.
|
||
#[test]
|
||
fn outline_frames_from_kept_geometry_match_a_new_renderer() {
|
||
let (d, ids) = outline_doc();
|
||
let outline = RenderOptions { outline: true, guide: ([0, 128, 255], true), background: Some([255; 4]), ..Default::default() };
|
||
let screen = RenderOptions { trace_views: true, background: Some([255; 4]), ..Default::default() };
|
||
let views = [Affine::IDENTITY, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), Affine::translate((6.0, -4.0))];
|
||
let mut moved = d.clone();
|
||
if let Some(n) = moved.node_mut(id_of(&ids, "past the clip")) {
|
||
n.transform(Affine::translate((3.0, 2.0)), false);
|
||
}
|
||
for opts in [&outline, &screen] {
|
||
let mut kept = Renderer::new();
|
||
for (d, view) in views.iter().chain(&views).map(|v| (&d, v)).chain([(&moved, &Affine::IDENTITY)]) {
|
||
let new = Renderer::new().render(d, 100, 100, *view, opts);
|
||
assert_eq!(kept.render(d, 100, 100, *view, opts).pixels, new.pixels, "outline: {}, view {view:?}", opts.outline);
|
||
}
|
||
}
|
||
// Outline view draws the members of the clip group unclipped, the tenth-of-a-point path as a
|
||
// dot, and no fills.
|
||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &outline);
|
||
assert!(img.pixel(58, 50)[0] < 200, "the member past the clipping path");
|
||
assert!(img.pixel(48, 56)[0] < 255, "the tenth-of-a-point path");
|
||
assert_eq!(img.pixel(15, 15), [255; 4], "the square's fill");
|
||
}
|
||
|
||
/// Outline view draws paths and compound paths from the geometry the renderer keeps for them,
|
||
/// the members of clip groups and the shapes of an Image Trace object showing its outlines
|
||
/// included: the next frame draws the same paths. A member of a clip group keeps its live-effect
|
||
/// geometry, and the members out of view are culled by bounds the renderer keeps.
|
||
#[test]
|
||
fn outline_view_draws_paths_from_kept_geometry() {
|
||
let (d, ids) = outline_doc();
|
||
let kept_paths = |r: &Renderer| -> HashMap<NodeId, Arc<BezPath>> { r.geom.values().filter_map(|e| Some((e.node.id, e.path.clone()?))).collect() };
|
||
let outline = RenderOptions { outline: true, ..Default::default() };
|
||
let screen = RenderOptions { trace_views: true, ..Default::default() };
|
||
let in_outline = [
|
||
"square",
|
||
"holed",
|
||
"fx path",
|
||
"fx compound",
|
||
"guide",
|
||
"clipping path",
|
||
"past the clip",
|
||
"member compound",
|
||
"pair rect",
|
||
"pair ellipse",
|
||
"nested clipping path",
|
||
"nested path",
|
||
"member fx",
|
||
"tiny",
|
||
"trace shape a",
|
||
"trace shape b",
|
||
"unpreviewed",
|
||
];
|
||
for (opts, drawn) in [(&outline, &in_outline[..]), (&screen, &["trace shape a", "trace shape b", "unpreviewed"][..])] {
|
||
let mut r = Renderer::new();
|
||
r.render(&d, 100, 100, Affine::IDENTITY, opts);
|
||
let first = kept_paths(&r);
|
||
r.render(&d, 100, 100, Affine::IDENTITY, opts);
|
||
let second = kept_paths(&r);
|
||
for name in drawn {
|
||
let id = id_of(&ids, name);
|
||
let path = first.get(&id).unwrap_or_else(|| panic!("outline: {}: no path kept for {name}", opts.outline));
|
||
assert!(second.get(&id).is_some_and(|p| Arc::ptr_eq(p, path)), "outline: {}: {name} drawn again from its kept path", opts.outline);
|
||
}
|
||
assert!(!first.contains_key(&id_of(&ids, "hidden")));
|
||
}
|
||
let mut r = Renderer::new();
|
||
r.render(&d, 100, 100, Affine::IDENTITY, &outline);
|
||
let member_fx = d.node(id_of(&ids, "member fx")).map(|n| n as *const Node as usize);
|
||
assert!(member_fx.is_some_and(|key| r.fx_geom.contains_key(&key)), "the member's live-effect geometry is kept");
|
||
// Zoomed in 4× on part of the clip group.
|
||
let mut r = Renderer::new();
|
||
r.render(&d, 100, 100, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), &outline);
|
||
for name in ["member compound", "pair", "member fx"] {
|
||
let id = id_of(&ids, name);
|
||
assert!(r.geom.values().any(|e| e.node.id == id && e.bounds.is_some() && e.path.is_none()), "{name}: culled by kept bounds");
|
||
}
|
||
}
|
||
|
||
#[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().unwrap();
|
||
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.extras_mut().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.extras_mut().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]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_dash_pattern_strokes_solid() {
|
||
// A negative dash value makes the pattern invalid (PDF `d`, SVG `stroke-dasharray`): the stroke
|
||
// is drawn solid. [-5, 3] used to send the dasher into an endless loop.
|
||
let line = |dash: Option<Vec<f64>>| {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((10.0, 50.0));
|
||
bp.line_to((90.0, 50.0));
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0));
|
||
n.appearance.stroke_mut().unwrap().dash = dash.map(|pattern| vectorcraft_doc::Dash { pattern, ..Default::default() });
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
};
|
||
let solid = render(&line(None));
|
||
for pattern in [vec![-5.0, 3.0], vec![3.0, -5.0], vec![4.0, f64::NAN], vec![0.0, 0.0]] {
|
||
let d = line(Some(pattern.clone()));
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
std::thread::spawn(move || tx.send(render(&d)).ok());
|
||
let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish"));
|
||
assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}");
|
||
}
|
||
}
|
||
|
||
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
|
||
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
|
||
/// Anti-aliased Images off, #394).
|
||
#[test]
|
||
fn images_take_their_nearest_pixel_without_smoothing() {
|
||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||
let img = image::RgbaImage::from_fn(2, 1, |x, _| image::Rgba(if x == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let mut d = Document::new(40.0, 20.0);
|
||
d.images.insert("bw".into(), ImageBlob::new("image/png", png));
|
||
let id = d.alloc_id();
|
||
let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
|
||
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||
// How many pixels across the image are neither black nor white.
|
||
let greys = |smooth_images| {
|
||
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
|
||
(0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count()
|
||
};
|
||
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
|
||
assert_eq!(greys(false), 0, "nearest: black, then white");
|
||
}
|
||
|
||
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
|
||
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
|
||
/// edges stay smooth.
|
||
#[test]
|
||
fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
|
||
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let layer = d.layers[0].id;
|
||
// Two triangles sharing the diagonal of the square (10, 10)–(90, 90).
|
||
for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] {
|
||
let mut b = vectorcraft_geom::BezPath::new();
|
||
b.move_to(pts[0]);
|
||
b.line_to(pts[1]);
|
||
b.line_to(pts[2]);
|
||
b.close_path();
|
||
let id = d.alloc_id();
|
||
d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap();
|
||
}
|
||
let draw = |supersample: bool| {
|
||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() };
|
||
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
|
||
};
|
||
// The palest pixel on the shared diagonal, away from the corners.
|
||
let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap();
|
||
assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam");
|
||
assert_eq!(palest(&draw(true)), 0, "supersampled: no seam");
|
||
// The outer edge is still smoothed: a pixel half covered is between white and red.
|
||
let ss = draw(true);
|
||
let edge = ss.pixel(50, 9);
|
||
assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}");
|
||
// The factor fits the size and the renderer's limits.
|
||
assert_eq!(supersample_factor(100, 100), Some(4));
|
||
assert_eq!(supersample_factor(2000, 2000), Some(3));
|
||
assert_eq!(supersample_factor(3000, 3000), Some(2));
|
||
assert_eq!(supersample_factor(5000, 5000), None);
|
||
assert_eq!(supersample_factor(40_000, 10), None);
|
||
}
|
||
|
||
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
|
||
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
|
||
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
|
||
#[test]
|
||
fn zero_length_subpaths_draw_dots_with_their_cap() {
|
||
let dots = |cap: vectorcraft_doc::LineCap| {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut bp = BezPath::new();
|
||
for at in [(30.0, 50.0), (70.0, 50.0)] {
|
||
bp.move_to(at);
|
||
bp.line_to(at);
|
||
}
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0));
|
||
n.appearance.stroke_mut().unwrap().cap = cap;
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
render(&d)
|
||
};
|
||
let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]);
|
||
let round = dots(vectorcraft_doc::LineCap::Round);
|
||
for x in [30, 70] {
|
||
assert_eq!(round.pixel(x, 50), black, "round dot at {x}");
|
||
assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}");
|
||
assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}");
|
||
}
|
||
let square = dots(vectorcraft_doc::LineCap::Square);
|
||
for x in [30, 70] {
|
||
assert_eq!(square.pixel(x, 50), black, "square dot at {x}");
|
||
assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}");
|
||
assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}");
|
||
}
|
||
let butt = dots(vectorcraft_doc::LineCap::Butt);
|
||
assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot");
|
||
}
|