mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
The tolerance that compares the points usvg makes of a <circle> or <ellipse> grew with the center's distance from the origin, so a circle of radius 400 at x 40,000,000 fell back to eight anchors. The tolerance is now a few f32 steps at the size of the coordinates, and the on-ellipse check allows the same steps relative to the smaller radius.
186 lines
8.6 KiB
Rust
186 lines
8.6 KiB
Rust
//! Simple shapes export as SVG basic shapes (#4652): axis-aligned rectangles, circles and
|
|
//! ellipses, straight lines, and polygons/polylines of straight segments. Everything else, and
|
|
//! rectangles and ellipses whose dashes would start elsewhere, stays `<path>`; the picture and the
|
|
//! reimported geometry don't change.
|
|
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, Node, StrokeLayer};
|
|
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, shapes};
|
|
use vectorcraft_svg::{ExportOptions, export, import};
|
|
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
|
|
|
fn painted(dash: bool) -> Appearance {
|
|
let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 3.0);
|
|
if dash {
|
|
st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false });
|
|
}
|
|
Appearance {
|
|
items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.9, 0.5, 0.1)))), AppearanceItem::Stroke(st)],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn doc(paths: Vec<(PathData, bool)>) -> Document {
|
|
let mut d = Document::new(200.0, 200.0);
|
|
let l = d.layers[0].id;
|
|
for (p, dash) in paths {
|
|
let n = Node::path(d.alloc_id(), p, painted(dash));
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
}
|
|
d
|
|
}
|
|
|
|
fn svg_of(p: PathData, dash: bool) -> String {
|
|
export(&doc(vec![(p, dash)]), &ExportOptions::default())
|
|
}
|
|
|
|
fn poly(pts: &[(f64, f64)], closed: bool) -> PathData {
|
|
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
|
PathData::single(SubPath::polyline(&pts, closed))
|
|
}
|
|
|
|
#[test]
|
|
fn each_simple_shape_writes_its_element() {
|
|
let cases = [
|
|
(shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), "<rect x=\"10\" y=\"20\" width=\"100\" height=\"50\""),
|
|
(shapes::ellipse(Rect::new(50.0, 50.0, 90.0, 90.0)), "<circle cx=\"70\" cy=\"70\" r=\"20\""),
|
|
(shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 40.0)), "<ellipse cx=\"40\" cy=\"25\" rx=\"30\" ry=\"15\""),
|
|
(shapes::line(Point::new(10.0, 10.0), Point::new(90.5, 40.0)), "<line x1=\"10\" y1=\"10\" x2=\"90.5\" y2=\"40\""),
|
|
(poly(&[(60.0, 185.0), (100.0, 185.0), (80.0, 135.0)], true), "<polygon points=\"60,185 100,185 80,135\""),
|
|
(poly(&[(10.0, 10.0), (50.0, 30.0), (90.0, 10.0)], false), "<polyline points=\"10,10 50,30 90,10\""),
|
|
];
|
|
for (p, want) in cases {
|
|
let svg = svg_of(p, false);
|
|
assert!(svg.contains(want), "{want}:\n{svg}");
|
|
assert!(!svg.contains("<path"), "{want}:\n{svg}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn numbers_are_the_written_precision() {
|
|
let opts = ExportOptions { decimals: 2, ..Default::default() };
|
|
let svg = export(&doc(vec![(shapes::rectangle(Rect::new(10.123, 20.0, 30.456, 40.0)), false)]), &opts);
|
|
assert!(svg.contains("<rect x=\"10.12\" y=\"20\" width=\"20.34\" height=\"20\""), "{svg}");
|
|
}
|
|
|
|
#[test]
|
|
fn turned_curved_and_dashed_shapes_keep_their_exact_form() {
|
|
let turned = |p: PathData| p.transformed(Affine::rotate_about(0.5, Point::new(60.0, 60.0)));
|
|
// A turned rectangle is still straight segments: a polygon, as the reference app writes one.
|
|
let svg = svg_of(turned(shapes::rectangle(Rect::new(20.0, 40.0, 100.0, 80.0))), false);
|
|
assert!(svg.contains("<polygon") && !svg.contains("<rect"), "{svg}");
|
|
// Polygons start their dashes where the path does; rectangles and ellipses don't.
|
|
let svg = svg_of(shapes::rectangle(Rect::new(20.0, 40.0, 100.0, 80.0)), true);
|
|
assert!(svg.contains("<polygon points=\"20,40 100,40 100,80 20,80\""), "{svg}");
|
|
let ellipse = shapes::ellipse(Rect::new(20.0, 40.0, 100.0, 80.0));
|
|
for (p, dash) in [(turned(ellipse.clone()), false), (ellipse, true)] {
|
|
let svg = svg_of(p, dash);
|
|
assert!(svg.contains("<path") && !svg.contains("<ellipse"), "{svg}");
|
|
}
|
|
// A curve and a zero-length line stay paths.
|
|
let mut curve = shapes::ellipse(Rect::new(20.0, 40.0, 100.0, 80.0));
|
|
curve.subpaths[0].anchors[0].h_out.y += 5.0;
|
|
for p in [curve, poly(&[(5.0, 5.0), (5.0, 5.0)], false)] {
|
|
assert!(svg_of(p, false).contains("<path"));
|
|
}
|
|
}
|
|
|
|
/// The exported SVG rendered by resvg on white at 1 px/pt.
|
|
fn resvg_render(svg: &str) -> Image {
|
|
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
|
let mut pm = resvg::tiny_skia::Pixmap::new(200, 200).unwrap();
|
|
pm.fill(resvg::tiny_skia::Color::WHITE);
|
|
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
|
Image { width: 200, height: 200, rgba: pm.data().to_vec() }
|
|
}
|
|
|
|
fn shapes_doc() -> Document {
|
|
doc(vec![
|
|
(shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), false),
|
|
(shapes::rectangle(Rect::new(120.0, 20.0, 190.0, 70.0)), true),
|
|
(shapes::ellipse(Rect::new(10.0, 80.0, 50.0, 120.0)), false),
|
|
(shapes::ellipse(Rect::new(60.0, 80.0, 140.0, 120.0)), false),
|
|
(shapes::line(Point::new(150.0, 80.0), Point::new(190.0, 120.0)), true),
|
|
(poly(&[(20.0, 185.0), (60.0, 185.0), (40.0, 135.0)], true), false),
|
|
(poly(&[(80.0, 140.0), (120.0, 180.0), (160.0, 140.0)], false), true),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn shape_elements_render_like_the_canvas() {
|
|
let d = shapes_doc();
|
|
let svg = export(&d, &ExportOptions::default());
|
|
assert!(!svg.contains("<path"), "{svg}");
|
|
assert_similar(&render_artboard(&d), &resvg_render(&svg), 24.0, 0.002);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_elements_reimport_with_the_same_geometry() {
|
|
let d = shapes_doc();
|
|
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
|
let bounds = |d: &Document| {
|
|
let mut v = Vec::new();
|
|
d.walk(|n| {
|
|
if !n.is_layer() {
|
|
v.push(n.geometric_bounds().unwrap());
|
|
}
|
|
});
|
|
v
|
|
};
|
|
let (a, b) = (bounds(&d), bounds(&back));
|
|
assert_eq!(a.len(), b.len());
|
|
for (a, b) in a.iter().zip(&b) {
|
|
let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().any(|v| v.abs() > 1e-3);
|
|
assert!(!far, "{a:?} came back as {b:?}");
|
|
}
|
|
}
|
|
|
|
/// The paths of the objects in `d`, in order.
|
|
fn paths(d: &Document) -> Vec<PathData> {
|
|
let mut v = Vec::new();
|
|
d.walk(|n| v.extend(n.path_data().cloned()));
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn circles_and_ellipses_import_as_the_ellipse_drawn() {
|
|
// What export writes comes back with the anchors and handles it was written from.
|
|
let drawn = [shapes::ellipse(Rect::new(10.0, 80.0, 50.0, 120.0)), shapes::ellipse(Rect::new(60.0, 80.0, 140.0, 120.0))];
|
|
let d = doc(drawn.iter().map(|p| (p.clone(), false)).collect());
|
|
let back = paths(&import(&export(&d, &ExportOptions::default())).unwrap());
|
|
assert_eq!(back.len(), 2);
|
|
for (want, got) in drawn.iter().zip(&back) {
|
|
let [w, g] = [want, got].map(|p| &p.subpaths[0].anchors);
|
|
assert!(got.subpaths.len() == 1 && got.subpaths[0].closed && g.len() == 4, "{got:?}");
|
|
// The same four anchors, starting on the right as an SVG ellipse does.
|
|
for a in g {
|
|
let same = w.iter().any(|b| a.p.distance(b.p) < 1e-4 && a.h_in.distance(b.h_in) < 1e-4 && a.h_out.distance(b.h_out) < 1e-4);
|
|
assert!(same, "{a:?} is not one of {w:?}");
|
|
}
|
|
assert!((g[0].p.x - want.bounds().unwrap().x1).abs() < 1e-4, "{g:?}");
|
|
}
|
|
// From another app, with a transform: four anchors on the axes, transformed.
|
|
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
|
<circle cx="50" cy="50" r="40" transform="translate(10 20)"/>
|
|
<ellipse cx="100" cy="100" rx="60" ry="20" transform="rotate(30 100 100)"/>
|
|
<ellipse cx="36.255" cy="154.118" rx="36.255" ry="5.421"/>
|
|
<circle cx="40000000" cy="0" r="400"/></svg>"#;
|
|
let back = paths(&import(svg).unwrap());
|
|
assert_eq!(back.len(), 4);
|
|
let circle = &back[0].subpaths[0].anchors;
|
|
let want: Vec<Point> = [(100.0, 70.0), (60.0, 110.0), (20.0, 70.0), (60.0, 30.0)].map(|(x, y)| Point::new(x, y)).into();
|
|
assert_eq!(circle.len(), 4);
|
|
for (a, w) in circle.iter().zip(&want) {
|
|
assert!(a.p.distance(*w) < 1e-4, "{circle:?}");
|
|
}
|
|
let turned = &back[1].subpaths[0].anchors;
|
|
let r = Affine::rotate_about(30f64.to_radians(), Point::new(100.0, 100.0));
|
|
assert!(turned.len() == 4 && turned[0].p.distance(r * Point::new(160.0, 100.0)) < 1e-3, "{turned:?}");
|
|
// However many curves the arcs of a flat ellipse make.
|
|
let flat = &back[2].subpaths[0].anchors;
|
|
assert!(flat.len() == 4 && flat[0].p.distance(Point::new(72.51, 154.118)) < 1e-3, "{flat:?}");
|
|
// A small circle far from the origin too.
|
|
let far = &back[3].subpaths[0].anchors;
|
|
assert!(far.len() == 4 && far[0].p.distance(Point::new(40_000_400.0, 0.0)) < 8.0, "{far:?}");
|
|
}
|