mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 16:38:07 +00:00
Native format: tests for exact saves, rounding halves, closed and tiny paths
Without `precision` 500 coordinates of every size reopen bit for bit and a second save writes the same bytes. Halves round away from zero, a closed path keeps its closing anchor on its first one, a shape smaller than one step collapses onto one point, coordinates at the edge of the canvas round to the asked places, and saving a rounded file again at the same precision writes the same bytes. docs/mcp.md states these rules.
This commit is contained in:
@@ -7,7 +7,9 @@ use std::sync::Arc;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::links::LinkInfo;
|
||||
use vectorcraft_doc::{Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, Node, NodeKind, OpacityMask, Symbol};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, MAX_COORD, Node, NodeKind, OpacityMask, Symbol,
|
||||
};
|
||||
use vectorcraft_format::{
|
||||
ICC_MIME, MAX_PRECISION, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with,
|
||||
};
|
||||
@@ -324,3 +326,89 @@ fn without_precision_coordinates_are_written_exactly() {
|
||||
assert_eq!(at(200), at(MAX_PRECISION));
|
||||
assert!(at(0).len() < at(MAX_PRECISION).len());
|
||||
}
|
||||
|
||||
/// A document holding one path through `points`.
|
||||
fn doc_with_path(points: &[Point], closed: bool) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let path = PathData::single(SubPath::polyline(points, closed));
|
||||
d.insert(Some(layer), 0, Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The first subpath of the first object of `d` saved at `precision` and opened again.
|
||||
fn saved_subpath(d: &Document, precision: Option<u8>) -> SubPath {
|
||||
let back = load(&save_with(d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap();
|
||||
let NodeKind::Path { path, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
|
||||
path.subpaths[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_precision_every_coordinate_reopens_bit_for_bit() {
|
||||
// 500 anchors at coordinates of every size and sign (a fixed pseudo-random sequence).
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = || {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
let unit = (seed >> 11) as f64 / (1u64 << 53) as f64 - 0.5;
|
||||
unit * 10f64.powi((seed % 13) as i32 - 6)
|
||||
};
|
||||
let mut points: Vec<Point> = (0..500).map(|_| Point::new(next(), next())).collect();
|
||||
points.extend([Point::new(MAX_COORD, -1e-300), Point::new(f64::MIN_POSITIVE, -3_999_999.123_456_789)]);
|
||||
let d = doc_with_path(&points, false);
|
||||
let bytes = save_with(&d, &SaveOptions::default()).unwrap();
|
||||
let got: Vec<Point> = saved_subpath(&d, None).anchors.iter().map(|a| a.p).collect();
|
||||
let bits = |ps: &[Point]| ps.iter().map(|p| (p.x.to_bits(), p.y.to_bits())).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&got), bits(&points));
|
||||
// Saving the reopened file writes the same bytes.
|
||||
assert_eq!(save_with(&load(&bytes).unwrap(), &SaveOptions::default()).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_rounds_halves_away_from_zero() {
|
||||
// These halves are exact in binary, so the scaled value is exactly .5.
|
||||
let d = doc_with_path(&[Point::new(0.125, -0.125), Point::new(0.375, -0.375), Point::new(1.5, -2.5)], false);
|
||||
let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::<Vec<_>>();
|
||||
assert_eq!(at(2), [Point::new(0.13, -0.13), Point::new(0.38, -0.38), Point::new(1.5, -2.5)]);
|
||||
assert_eq!(at(0), [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(2.0, -3.0)]);
|
||||
// `1.005` is stored as slightly less than the decimal, so it goes down.
|
||||
let d = doc_with_path(&[Point::new(1.005, -1.005)], false);
|
||||
assert_eq!(saved_subpath(&d, Some(2)).anchors[0].p, Point::new(1.0, -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_closed_path_stays_closed_on_its_first_anchor() {
|
||||
let first = Point::new(10.123456, 20.987654);
|
||||
let d = doc_with_path(&[first, Point::new(50.55555, 20.4444), Point::new(30.01, 60.996), first], true);
|
||||
let sp = saved_subpath(&d, Some(2));
|
||||
assert!(sp.closed);
|
||||
assert_eq!(sp.anchors.len(), 4);
|
||||
assert_eq!(sp.anchors[0].p, Point::new(10.12, 20.99));
|
||||
assert_eq!(sp.anchors[3].p, sp.anchors[0].p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_smaller_than_the_precision_keeps_its_anchors_on_one_point() {
|
||||
let d = doc_with_path(&[Point::new(5.0001, 5.0001), Point::new(5.0004, 5.0001), Point::new(5.0002, 5.0004)], true);
|
||||
let sp = saved_subpath(&d, Some(3));
|
||||
assert!(sp.closed);
|
||||
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(5.0, 5.0); 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_rounds_coordinates_at_the_edge_of_the_canvas() {
|
||||
let points = [Point::new(3_999_999.123_456_789, -3_999_999.987_654_321), Point::new(MAX_COORD, -MAX_COORD)];
|
||||
let d = doc_with_path(&points, false);
|
||||
let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::<Vec<_>>();
|
||||
assert_eq!(at(3), [Point::new(3_999_999.123, -3_999_999.988), points[1]]);
|
||||
for (got, want) in at(MAX_PRECISION).iter().zip(&points) {
|
||||
assert!((got.x - want.x).abs() <= 1e-9 && (got.y - want.y).abs() <= 1e-9, "{got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_rounded_file_at_the_same_precision_again_writes_the_same_bytes() {
|
||||
let o = SaveOptions { precision: Some(2), ..SaveOptions::default() };
|
||||
let once = save_with(&precise_doc(), &o).unwrap();
|
||||
assert_eq!(save_with(&load(&once).unwrap(), &o).unwrap(), once);
|
||||
}
|
||||
|
||||
+4
-1
@@ -639,7 +639,10 @@ their content). `version: 2` or `1` writes a file older VectorCraft versions ope
|
||||
compressed), and `preview: true` embeds a PNG of the first artboard, at most 256 px on its longer side.
|
||||
`precision: 4` (0 to 9) rounds the anchors and handles of paths to that many decimal places (in the layers, symbols,
|
||||
graph designs, patterns, opacity masks and brushes); colors, transforms, opacity and type are written exactly.
|
||||
The embedded preview and the PDF that `pdfCompatible: true` adds show the rounded paths. Without `precision` a save writes the coordinates exactly, and Save does not remember it. A `.ai` file ignores
|
||||
Each coordinate goes to the nearest step of its stored binary value, and a value exactly halfway rounds away from
|
||||
zero (`0.125` at 2 places is `0.13`); `1.005` is stored as slightly less than that and is written as `1.0`. Each anchor is rounded on
|
||||
its own, so a closed path stays closed and a shape smaller than one step keeps its anchors on one point. Opening the
|
||||
file and saving it again does not bring back the dropped digits. The embedded preview and the PDF that `pdfCompatible: true` adds show the rounded paths. Without `precision` a save writes the coordinates exactly, and Save does not remember it. A `.ai` file ignores
|
||||
`precision`. A save writes
|
||||
a temporary file and renames it over the old one, so a failed save never damages the file; only the images the
|
||||
document uses are written, and document keys written by a newer version are kept.
|
||||
|
||||
Reference in New Issue
Block a user