mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 00:09:41 +00:00
- A custom arrowhead path allows at most 4,096 subpaths; stroke.set refuses a longer one and reading drops it. - A symbol whose shapes cannot be united or clipped is listed in skipped instead of becoming a head that misses part of its art. - The arrowheads file is refused when its document unpacks to more than 64 MB. - Edit Arrowheads creates the starter file only when none exists and otherwise opens the file unchanged. - After a save to the arrowheads file, the heads are read back from the written file. - The plug-in argument check accepts path under startArrow.Custom and endArrow.Custom in stroke.set. - A custom head's menu preview is scaled to fit its box, and rows outside the visible part of the menu do not render previews.
537 lines
26 KiB
Rust
537 lines
26 KiB
Rust
use super::*;
|
||
|
||
#[test]
|
||
fn hidden_attributes_show_again() {
|
||
let mut a = Appearance::default_art();
|
||
assert!(!a.has_hidden() && !a.show_all());
|
||
a.stroke_mut().unwrap().visible = false;
|
||
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
|
||
assert!(a.has_hidden());
|
||
assert!(a.show_all());
|
||
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
|
||
}
|
||
|
||
#[test]
|
||
fn basic_appearance() {
|
||
let a = Appearance::default_art();
|
||
assert!(a.is_basic());
|
||
assert_eq!(a.fill_paint(), Paint::solid(Color::WHITE));
|
||
assert_eq!(a.stroke_width(), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
|
||
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
|
||
use vectorcraft_geom::{Point, Rect};
|
||
let placed = |x: f64| {
|
||
let mut g = GradientPaint::new(Gradient::default());
|
||
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0, focal: None });
|
||
Paint::Gradient(Box::new(g))
|
||
};
|
||
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
|
||
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
|
||
let start = |p: Paint| match p {
|
||
Paint::Gradient(g) => g.geom.unwrap().start,
|
||
_ => panic!("a gradient"),
|
||
};
|
||
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
|
||
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn items_by_index() {
|
||
let mut a = Appearance::default_art();
|
||
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
|
||
// [Fill white, Stroke black, Fill none]
|
||
assert_eq!(a.fill_at(None).unwrap().paint, Paint::None);
|
||
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::WHITE));
|
||
assert!(a.fill_at(Some(1)).is_none() && a.stroke_at(Some(0)).is_none() && a.fill_at(Some(9)).is_none());
|
||
assert_eq!(a.item_of_kind(Some(1), false), Some(1));
|
||
assert_eq!(a.item_of_kind(Some(1), true), None);
|
||
assert_eq!(a.fill_for(Some(1)).unwrap().paint, Paint::None);
|
||
assert!(a.set_fill_at(Some(0), Paint::solid(Color::BLACK)));
|
||
assert!(!a.set_stroke_at(Some(0), Paint::None));
|
||
assert_eq!(a.fill_at(Some(0)).unwrap().paint, Paint::solid(Color::BLACK));
|
||
a.effects_mut(Some(1)).unwrap().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
|
||
assert_eq!(a.effects_at(Some(1)).unwrap().len(), 1);
|
||
assert!(a.effects_at(None).unwrap().is_empty() && a.effects_mut(Some(3)).is_none());
|
||
assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill"));
|
||
}
|
||
|
||
#[test]
|
||
fn basic_means_one_plain_fill_and_stroke() {
|
||
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
|
||
let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() };
|
||
assert!(!a.is_basic(), "two strokes");
|
||
a.items.pop();
|
||
assert!(a.is_basic() && Appearance::default().is_basic());
|
||
let mut b = Appearance::default_art();
|
||
b.stroke_mut().unwrap().visible = false;
|
||
assert!(!b.is_basic(), "a hidden stroke");
|
||
let mut c = Appearance::default_art();
|
||
c.fill_mut().unwrap().opacity = 0.5;
|
||
assert!(!c.is_basic(), "fill opacity");
|
||
let mut d = Appearance::default_art();
|
||
d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
|
||
assert!(!d.is_basic(), "two fills");
|
||
let mut e = Appearance::default_art();
|
||
e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
|
||
assert!(!e.is_basic(), "an effect");
|
||
}
|
||
|
||
#[test]
|
||
fn set_creates_missing() {
|
||
let mut a = Appearance::default();
|
||
a.set_fill(Paint::solid(Color::BLACK));
|
||
a.set_stroke(Paint::solid(Color::WHITE));
|
||
assert_eq!(a.items.len(), 2);
|
||
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
|
||
}
|
||
|
||
/// #852: pen pressure is kept simplified along the stroke and read back by position.
|
||
#[test]
|
||
fn pressure_profile_samples_simplify_and_interpolate() {
|
||
// A steady ramp keeps only its ends; a bump keeps its peak.
|
||
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
|
||
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
|
||
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
|
||
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
|
||
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
|
||
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
|
||
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
|
||
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
|
||
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
|
||
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
|
||
// A long noisy stroke stays bounded.
|
||
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
|
||
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
|
||
// Out-of-order points (a hand-edited file) still read within 0..1.
|
||
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
|
||
assert!((0.0..=1.0).contains(&odd.at(0.5)));
|
||
}
|
||
|
||
#[test]
|
||
fn pressure_profile_extends_and_reverses() {
|
||
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
|
||
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
|
||
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
|
||
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
|
||
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
|
||
// A side without pressure is half way.
|
||
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
|
||
assert_eq!(half.at(0.25), PressureProfile::MID);
|
||
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
|
||
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
|
||
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
|
||
}
|
||
|
||
/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so
|
||
/// 170° then −170° turns through 180°, not through 0°.
|
||
#[test]
|
||
fn angle_profiles_join_samples_the_shorter_way_round() {
|
||
let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap();
|
||
assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]);
|
||
assert_eq!(across.at(0.5), 180.0);
|
||
assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75));
|
||
// Turns past a whole turn keep counting, and read back within −180..180°.
|
||
let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap();
|
||
assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}");
|
||
assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}");
|
||
// Junk is dropped; nothing at all is no profile, and no profile reads 0°.
|
||
let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap();
|
||
assert_eq!(junk.points, vec![(0.2, 40.0)]);
|
||
assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none());
|
||
assert_eq!(AngleProfile::default().at(0.3), 0.0);
|
||
let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap();
|
||
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
|
||
// Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the
|
||
// angles stay what they were.
|
||
let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] };
|
||
let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap();
|
||
assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
|
||
assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none());
|
||
assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone()));
|
||
assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]);
|
||
// A part that turned more than half a turn between two of its samples keeps that turn when
|
||
// the path is continued, and so does the new part.
|
||
let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap();
|
||
assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]);
|
||
let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] };
|
||
let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap();
|
||
assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}");
|
||
assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}");
|
||
}
|
||
|
||
/// Strokes saved before pen pressure load as they were, and are written without it.
|
||
#[test]
|
||
fn strokes_without_pressure_round_trip_unchanged() {
|
||
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
|
||
for key in ["pressure", "tilt", "bearing", "rotation"] {
|
||
assert!(json.get(key).is_none(), "{key}");
|
||
}
|
||
let st: StrokeLayer = serde_json::from_value(json).unwrap();
|
||
assert_eq!(st.pressure, None);
|
||
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
|
||
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
|
||
assert_eq!(back, pressed);
|
||
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
|
||
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
|
||
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
|
||
// The pen's tilt, bearing and rotation load, save and stay behind the same way.
|
||
let posed = StrokeLayer {
|
||
tilt: Some(PressureProfile::sample()),
|
||
bearing: Some(AngleProfile::sample()),
|
||
rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }),
|
||
..back
|
||
};
|
||
let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap();
|
||
assert_eq!(again, posed);
|
||
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
|
||
*ap.stroke_mut().unwrap() = posed;
|
||
let st = ap.without_pressure().stroke().cloned().unwrap();
|
||
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
|
||
}
|
||
|
||
/// The Lens profile's widths at its middle, a quarter of the way along and past its end.
|
||
#[test]
|
||
fn profile_interp() {
|
||
let p = WidthProfile::lens();
|
||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||
// The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the
|
||
// 0.5 a straight taper would give.
|
||
assert_eq!(p.at(0.25), (0.75, 0.75));
|
||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||
}
|
||
|
||
/// The width runs smoothly between points: it passes through them, has one slope either side
|
||
/// of one, and never overshoots the widths its points set.
|
||
#[test]
|
||
fn profile_is_smooth_between_points() {
|
||
let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)];
|
||
assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12);
|
||
let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6;
|
||
// A local minimum at the middle point is flat there, so the two sides agree.
|
||
assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5));
|
||
for k in 0..=100 {
|
||
let w = WidthProfile::at_points(&p, k as f64 / 100.0).0;
|
||
assert!((0.2..=1.0).contains(&w), "{w} at {k}");
|
||
}
|
||
// The side with no points of its own stays flat.
|
||
for k in 0..=100 {
|
||
assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12);
|
||
}
|
||
}
|
||
|
||
/// A run with only two points takes its secant as both end slopes, so it stays a straight
|
||
/// taper on each side instead of becoming a smoothstep.
|
||
#[test]
|
||
fn profile_two_point_runs_are_linear() {
|
||
let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)];
|
||
for k in 0..=100 {
|
||
let t = k as f64 / 100.0;
|
||
let (l, r) = WidthProfile::at_points(&p, t);
|
||
assert!((l - t).abs() < 1e-12, "left {l} at {t}");
|
||
assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}");
|
||
}
|
||
}
|
||
|
||
/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75,
|
||
/// above the 0.5 a straight line between its points would give.
|
||
#[test]
|
||
fn profile_lens_is_convex() {
|
||
let p = WidthProfile::lens();
|
||
assert!((p.at(0.25).0 - 0.75).abs() < 1e-12);
|
||
assert!((p.at(0.75).0 - 0.75).abs() < 1e-12);
|
||
for k in 1..25 {
|
||
let t = k as f64 / 100.0;
|
||
assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0);
|
||
assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t);
|
||
}
|
||
}
|
||
|
||
/// A step still steps, and the smooth runs either side meet it flat.
|
||
#[test]
|
||
fn profile_steps_stay_steps() {
|
||
let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)];
|
||
assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0));
|
||
assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0));
|
||
assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0));
|
||
assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0));
|
||
assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0));
|
||
}
|
||
|
||
/// Moving a point only reshapes the segments beside it (local influence), not the whole path.
|
||
#[test]
|
||
fn profile_points_only_affect_the_curve_around_them() {
|
||
// A monotone run, so no neighbouring slope is held flat by a local extremum.
|
||
let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)];
|
||
let mut b = a;
|
||
b[3] = (0.75, 0.9, 0.9);
|
||
// Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the
|
||
// curve: nothing before p[1] may move.
|
||
for k in 0..=24 {
|
||
let t = k as f64 / 100.0;
|
||
assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}");
|
||
}
|
||
assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3));
|
||
}
|
||
|
||
#[test]
|
||
fn profile_presets_round_trip_their_ids() {
|
||
for p in WidthProfile::PRESETS {
|
||
let prof = WidthProfile::preset(p.id).unwrap();
|
||
assert_eq!(prof.preset_id(), Some(p.id));
|
||
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
|
||
}
|
||
assert_eq!(WidthProfile::id_of(None), "uniform");
|
||
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
|
||
assert!(WidthProfile::preset("nope").is_none());
|
||
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
|
||
}
|
||
|
||
#[test]
|
||
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
|
||
// Saved files store these names: they may only ever be appended to.
|
||
const SAVED: [&str; 41] = [
|
||
"Triangle",
|
||
"TriangleOpen",
|
||
"Circle",
|
||
"CircleOpen",
|
||
"Square",
|
||
"SquareOpen",
|
||
"Bar",
|
||
"Diamond",
|
||
"Arrow",
|
||
"ArrowOpen",
|
||
"Barbed",
|
||
"HalfArrowLeft",
|
||
"HalfArrowRight",
|
||
"Concave",
|
||
"DoubleBar",
|
||
"Feather",
|
||
"DotOnBar",
|
||
"Chevron",
|
||
"DoubleArrow",
|
||
"Target",
|
||
"Star",
|
||
"Cross",
|
||
"Plus",
|
||
"Hexagon",
|
||
"HexagonOpen",
|
||
"Tag",
|
||
"TagOpen",
|
||
"HalfCircle",
|
||
"Drop",
|
||
"Slash",
|
||
"DoubleSlash",
|
||
"DiamondOpen",
|
||
"TriangleReverse",
|
||
"Swallowtail",
|
||
"Bracket",
|
||
"Fork",
|
||
"Leaf",
|
||
"Kite",
|
||
"TriangleBar",
|
||
"Oval",
|
||
"LineArrow",
|
||
];
|
||
for name in SAVED {
|
||
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
|
||
assert_eq!(serde_json::to_value(&a).unwrap(), name);
|
||
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
|
||
}
|
||
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
|
||
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
|
||
}
|
||
|
||
/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line.
|
||
fn custom_head() -> Arrowhead {
|
||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true));
|
||
Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path)))
|
||
}
|
||
|
||
#[test]
|
||
fn custom_arrowheads_carry_their_art_and_measure_it() {
|
||
let head = custom_head();
|
||
assert_eq!((head.name(), head.label()), ("Wedge", "Custom"));
|
||
assert_eq!(head.reach(), 3.0f64.hypot(2.0));
|
||
assert_eq!(head.half_width(), 2.0);
|
||
let Arrowhead::Custom(c) = &head else { panic!("custom") };
|
||
assert_eq!(c.stop(), 1.5, "the line stops halfway along the head");
|
||
// Saved with the stroke, art and all, beside the built-in heads' names.
|
||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||
(st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone()));
|
||
let json = serde_json::to_value(&st).unwrap();
|
||
assert_eq!(json["start_arrow"], "Triangle");
|
||
assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge");
|
||
let back: StrokeLayer = serde_json::from_value(json).unwrap();
|
||
assert_eq!(back, st);
|
||
// Extended, the tip sits the stop past the end point; on it, only the reach counts.
|
||
assert_eq!(head.length(), 1.5 + head.reach());
|
||
assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0);
|
||
st.arrow_align = ArrowAlign::Tip;
|
||
assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_arrowheads_out_of_bounds_draw_nothing() {
|
||
let Arrowhead::Custom(c) = custom_head() else { panic!("custom") };
|
||
let mut path = c.path.clone();
|
||
path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0));
|
||
// A file can hold what `new` drops: reading it drops it too.
|
||
let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap();
|
||
let far: CustomArrowhead = serde_json::from_value(json).unwrap();
|
||
assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0));
|
||
assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0));
|
||
let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1];
|
||
let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true)));
|
||
assert!(many.path.subpaths.is_empty(), "too many anchors");
|
||
// Empty subpaths add no anchors, and still count.
|
||
let mut hollow = c.path.clone();
|
||
hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS));
|
||
assert!(!CustomArrowhead::fits(&hollow), "too many subpaths");
|
||
let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap();
|
||
let hollow: CustomArrowhead = serde_json::from_value(json).unwrap();
|
||
assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back");
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_align_defaults_to_extend_and_round_trips() {
|
||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||
assert_eq!(st.arrow_align, ArrowAlign::Extend);
|
||
// The default is not written, so older readers see the same JSON as before.
|
||
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
|
||
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
|
||
assert_eq!(old.arrow_align, ArrowAlign::Extend);
|
||
st.arrow_align = ArrowAlign::Tip;
|
||
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
|
||
assert_eq!(back, st);
|
||
}
|
||
|
||
#[test]
|
||
fn outset_covers_arrowheads() {
|
||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
|
||
a.stroke_mut().unwrap().join = LineJoin::Round;
|
||
assert_eq!(a.outset(), 2.0);
|
||
let st = a.stroke_mut().unwrap();
|
||
st.end_arrow = Some(Arrowhead::Triangle);
|
||
st.arrow_scale = (100.0, 200.0);
|
||
assert_eq!(st.arrow_weight(false), 4.0);
|
||
assert_eq!(st.arrow_weight(true), 8.0);
|
||
let reach = Arrowhead::Triangle.reach();
|
||
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, reach * 8.0 + 2.0));
|
||
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
|
||
assert_eq!(a.outset(), reach * 8.0 + 2.0);
|
||
// The line arrow's arms spread twice as wide, so it reaches further from its tip.
|
||
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::LineArrow);
|
||
assert!(Arrowhead::LineArrow.reach() > reach);
|
||
assert_eq!(a.outset(), Arrowhead::LineArrow.reach() * 8.0 + 2.0);
|
||
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
|
||
a.stroke_mut().unwrap().width = 0.01;
|
||
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
|
||
}
|
||
|
||
#[test]
|
||
fn outset_depends_on_align() {
|
||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||
a.stroke_mut().unwrap().join = LineJoin::Round;
|
||
assert_eq!(a.outset(), 5.0);
|
||
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
|
||
assert_eq!(a.outset(), 10.0);
|
||
// Open paths stroke inside-aligned strokes centred.
|
||
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
|
||
assert_eq!(a.outset(), 5.0);
|
||
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
|
||
}
|
||
|
||
#[test]
|
||
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
|
||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||
// Miter joins can spike out to the limit × half the weight.
|
||
assert_eq!(a.outset(), 10.0);
|
||
let st = a.stroke_mut().unwrap();
|
||
st.join = LineJoin::Bevel;
|
||
st.cap = LineCap::Square;
|
||
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
|
||
let st = a.stroke_mut().unwrap();
|
||
st.cap = LineCap::Round;
|
||
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
|
||
assert_eq!(st.profile_max(), 2.5);
|
||
assert_eq!(a.outset(), 2.5);
|
||
a.stroke_mut().unwrap().paint = Paint::None;
|
||
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
|
||
}
|
||
|
||
#[test]
|
||
fn contents_slot_follows_item_edits_and_round_trips() {
|
||
let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None));
|
||
let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() };
|
||
assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default");
|
||
a.set_contents_at(1);
|
||
assert!(!a.is_basic());
|
||
// [0 | 1 2]: removing below shifts the slot, removing above doesn't.
|
||
a.remove_item(2);
|
||
assert_eq!(a.contents_at(), 1);
|
||
a.remove_item(0);
|
||
assert_eq!((a.contents_at(), a.contents_index), (0, None));
|
||
// Inserting next to the slot joins the item below it.
|
||
a.items.push(fill());
|
||
a.set_contents_at(1); // [0 | 1]
|
||
a.insert_item(1, fill());
|
||
assert_eq!(a.contents_at(), 2);
|
||
a.insert_item(3, fill()); // [0 1 | 2 3]
|
||
assert_eq!(a.contents_at(), 2);
|
||
// Moves keep their side when landing next to the slot, cross it otherwise.
|
||
assert_eq!(a.move_item(3, 2), Some(2));
|
||
assert_eq!(a.contents_at(), 2, "stays above");
|
||
a.move_item(3, 0);
|
||
assert_eq!(a.contents_at(), 3, "crossed below");
|
||
a.move_item(0, 3);
|
||
assert_eq!(a.contents_at(), 2, "crossed above");
|
||
assert_eq!(a.move_item(9, 0), None);
|
||
// Saved only when set; older files load with every item above.
|
||
let json = serde_json::to_string(&a).unwrap();
|
||
assert!(json.contains("\"contents_index\":2"));
|
||
assert_eq!(serde_json::from_str::<Appearance>(&json).unwrap(), a);
|
||
let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap();
|
||
assert_eq!(old.contents_index, None);
|
||
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
|
||
}
|
||
|
||
/// The issue's case (#4174): two circles with different fills, the same stroke and the same
|
||
/// effect share the stroke and the effect, not the fills; a shared stroke at another place in one
|
||
/// stack maps to its own index there; nothing shared shares nothing; equal stacks share all.
|
||
#[test]
|
||
fn mixed_appearances_share_their_equal_rows() {
|
||
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 11.0));
|
||
let fill = |c: Color| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
|
||
let feather = Effect { id: "stylize.feather".into(), params: serde_json::json!({"radius": 5}), visible: true };
|
||
let a = Appearance { items: vec![fill(Color::rgb(1.0, 1.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
|
||
let b = Appearance { items: vec![fill(Color::rgb(1.0, 0.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
|
||
let shared = SharedStack::of(&[&a, &b]);
|
||
assert_eq!(shared.items, vec![vec![1, 1]], "the stroke, not the fills");
|
||
assert_eq!(shared.effects, vec![vec![0, 0]]);
|
||
// The stroke under an extra fill in the second object.
|
||
let c =
|
||
Appearance { items: vec![fill(Color::BLACK), fill(Color::WHITE), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
|
||
let shared = SharedStack::of(&[&a, &c]);
|
||
assert_eq!(shared.items, vec![vec![1, 2]]);
|
||
assert_eq!(shared.item_in(1, 1), Some(2));
|
||
assert_eq!(shared.item_in(0, 1), None);
|
||
// Nothing alike.
|
||
let d = Appearance { items: vec![fill(Color::WHITE)], ..Appearance::default() };
|
||
let none = SharedStack::of(&[&a, &d]);
|
||
assert!(none.items.is_empty() && none.effects.is_empty());
|
||
// Equal stacks share everything, each row where it is.
|
||
assert_eq!(SharedStack::of(&[&a, &a.clone(), &a.clone()]).items, vec![vec![0, 0, 0], vec![1, 1, 1]]);
|
||
}
|
||
|
||
/// Matches keep their stacking order: two equal rows in the opposite order match once.
|
||
#[test]
|
||
fn aligned_rows_never_cross() {
|
||
assert_eq!(align(&[1, 2], &[2, 1], |a, b| a == b).iter().filter(|m| m.is_some()).count(), 1);
|
||
assert_eq!(align(&[1, 2, 3], &[9, 1, 3], |a, b| a == b), vec![Some(1), None, Some(2)]);
|
||
assert_eq!(align::<i32>(&[], &[1], |a, b| a == b), Vec::<Option<usize>>::new());
|
||
}
|