mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
object.path.offsetPath, object.path.simplify, object.path.splitIntoGrid,
object.path.cleanUp, object.pathfinder.trap and addon.contour.set read
their params with helpers that fell back to the default for a value of
the wrong type: "offset": true offset by 10 pt, "joins": 3 gave mitered
joins, "addGuides": "yes" added no guides. Each such value is now an
error naming the param, and the document and the undo history stay as
they were. An omitted or null param keeps its default, and a distance
still takes a string with a unit ("5 mm"). An unknown `joins` name is
refused as well.
179 lines
7.9 KiB
Rust
179 lines
7.9 KiB
Rust
//! Tests for Pathfinder panel › Trap (`object.pathfinder.trap`).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
use vectorcraft_geom::Rect;
|
||
|
||
use crate::*;
|
||
|
||
const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 };
|
||
const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 };
|
||
|
||
/// A yellow rectangle (0..50) abutting a blue one (50..100), both 40 pt tall, both selected.
|
||
fn abutting() -> (Session, NodeId, NodeId) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let mut rect = |x: f64, c: Color| {
|
||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
||
s.edit("fill", |d, _| {
|
||
if let Some(n) = d.node_mut(id) {
|
||
n.appearance.set_fill(Paint::solid(c));
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
id
|
||
};
|
||
let (a, b) = (rect(0.0, YELLOW), rect(50.0, BLUE));
|
||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
(s, a, b)
|
||
}
|
||
|
||
fn ids(v: &Value) -> Vec<NodeId> {
|
||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||
}
|
||
|
||
fn near(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 0.02
|
||
}
|
||
|
||
/// The traps of the trap group the command made.
|
||
fn traps(s: &Session, v: &Value) -> Vec<Node> {
|
||
let g = node(s, ids(v)[0]);
|
||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||
g.children().unwrap().iter().map(|c| (**c).clone()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn trap_spreads_the_lighter_colour_into_the_darker_along_the_shared_edge() {
|
||
let (mut s, a, b) = abutting();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo_len = s.doc().unwrap().history.undo.len();
|
||
let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100})).unwrap();
|
||
let t = traps(&s, &r);
|
||
assert_eq!(t.len(), 1);
|
||
let bb = t[0].geometric_bounds().unwrap();
|
||
assert!(near(bb.x0, 50.0) && near(bb.x1, 52.0) && near(bb.y0, 0.0) && near(bb.y1, 40.0), "2 pt into the blue: {bb:?}");
|
||
let fill = t[0].appearance.fill().unwrap();
|
||
assert_eq!(fill.paint.color(), Some(YELLOW), "filled with the lighter colour");
|
||
assert!(fill.overprint, "set to overprint");
|
||
assert!(t[0].appearance.stroke_paint().is_none());
|
||
// The originals stay; the traps sit directly above the topmost one and are selected.
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(node(&s, a), *before.node(a).unwrap());
|
||
assert_eq!(node(&s, b), *before.node(b).unwrap());
|
||
let (parent, ib, _) = st.doc.position(b).unwrap();
|
||
let (gparent, ig, _) = st.doc.position(ids(&r)[0]).unwrap();
|
||
assert_eq!((gparent, ig), (parent, ib + 1));
|
||
assert_eq!(st.selection.objects, ids(&r));
|
||
// One undo step.
|
||
assert_eq!(st.history.undo.len(), undo_len + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
}
|
||
|
||
#[test]
|
||
fn height_width_and_tint_reduction_shape_and_tint_the_trap() {
|
||
let (mut s, _, _) = abutting();
|
||
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "1 pt", "tintReduction": 25})).unwrap();
|
||
let t = traps(&s, &r);
|
||
let [_, _, y, _] = t[0].appearance.fill_paint().color().unwrap().to_cmyk();
|
||
assert!((y - 0.25).abs() < 1e-4, "yellow at 25%: {y}");
|
||
let bb = t[0].geometric_bounds().unwrap();
|
||
assert!(near(bb.x1, 51.0), "{bb:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn reverse_traps_spread_the_darker_colour_into_the_lighter() {
|
||
let (mut s, _, _) = abutting();
|
||
let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100, "reverseTraps": true})).unwrap();
|
||
let t = traps(&s, &r);
|
||
let bb = t[0].geometric_bounds().unwrap();
|
||
assert!(near(bb.x0, 48.0) && near(bb.x1, 50.0), "2 pt into the yellow: {bb:?}");
|
||
assert_eq!(t[0].appearance.fill_paint().color(), Some(BLUE));
|
||
}
|
||
|
||
#[test]
|
||
fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() {
|
||
let (mut s, a, b) = abutting();
|
||
s.edit("apart", |d, _| {
|
||
if let Some(n) = d.node_mut(b) {
|
||
n.kind = NodeKind::Path {
|
||
path: vectorcraft_geom::shapes::rectangle(Rect::new(200.0, 0.0, 250.0, 40.0)),
|
||
rule: vectorcraft_geom::FillRule::NonZero,
|
||
live: None,
|
||
clipping: false,
|
||
guide: false,
|
||
};
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo_len = s.doc().unwrap().history.undo.len();
|
||
let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("no edges of different colours meet"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
|
||
// One object alone has nothing to trap with.
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("two or more objects"), "{e}");
|
||
// Given as ids, the objects are selected for the command, and its failure puts the
|
||
// selection back.
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
let e = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap_err().to_string();
|
||
assert!(e.contains("no edges of different colours meet"), "{e}");
|
||
assert!(s.doc().unwrap().selection.objects.is_empty(), "{:?}", s.doc().unwrap().selection.objects);
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
assert!(s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).is_err());
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_range_and_unreadable_numbers_are_refused() {
|
||
let (mut s, _, _) = abutting();
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo_len = s.doc().unwrap().history.undo.len();
|
||
for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] {
|
||
assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}");
|
||
}
|
||
// A value of the wrong type is refused with an error naming the param, and changes nothing.
|
||
for (p, want) in [
|
||
(json!({"thickness": "NaN"}), "`thickness` must be a number, not \"NaN\""),
|
||
(json!({"thickness": true}), "`thickness` must be a number, not true"),
|
||
(json!({"heightWidth": [100]}), "`heightWidth` must be a number, not [100]"),
|
||
(json!({"tintReduction": false}), "`tintReduction` must be a number, not false"),
|
||
(json!({"trapsWithProcessColor": "yes"}), "`trapsWithProcessColor` must be true or false"),
|
||
(json!({"reverseTraps": 1}), "`reverseTraps` must be true or false"),
|
||
] {
|
||
let r = s.execute("object.pathfinder.trap", &p);
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
|
||
// Null keeps the default thickness, 0.25 pt; a thickness with a unit is read as a distance.
|
||
let r = s.execute("object.pathfinder.trap", &json!({"thickness": null, "reverseTraps": null, "tintReduction": 100})).unwrap();
|
||
let bb = traps(&s, &r)[0].geometric_bounds().unwrap();
|
||
assert!(near(bb.x1, 50.25), "{bb:?}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "0.1 in", "tintReduction": 100})).unwrap();
|
||
let bb = traps(&s, &r)[0].geometric_bounds().unwrap();
|
||
assert!(near(bb.x1, 57.2), "{bb:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn ids_name_the_objects_to_trap() {
|
||
let (mut s, a, b) = abutting();
|
||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||
let r = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap();
|
||
assert_eq!(traps(&s, &r).len(), 1);
|
||
}
|