Files
cadcraft/crates/engine/tests/it/mline.rs
T
aiongg 9cf115e808 CI: one test binary per crate, nextest, line tables only (#1989)
* Build: line tables only in dev, no debug info for dependencies

* Tests: one integration-test binary per crate

Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.

* xtask ci: nextest, tests without examples, one wasm check call
2026-10-23 13:34:26 -04:00

310 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Multilines (#510): the MLINE entity with its style's elements, MLSTYLE, MLEDIT, and the
//! edits, grips, snaps, selection and rendering every object gets.
use cadcraft_doc::{EntityKind, Handle, MLine, Space};
use cadcraft_engine::Session;
use cadcraft_engine::snap::{self, mode};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
fn h(v: &Value) -> Handle {
Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap()
}
fn mline(s: &Session, hd: Handle) -> MLine {
match &s.doc().unwrap().entity(hd).unwrap().kind {
EntityKind::MLine(m) => m.clone(),
k => panic!("not a multiline: {k:?}"),
}
}
/// Element pieces of a multiline, rounded, sorted: (x1, y1, x2, y2).
fn pieces(m: &MLine) -> Vec<[f64; 4]> {
let r = |v: f64| (v * 1e6).round() / 1e6 + 0.0;
let mut v: Vec<[f64; 4]> = m.geometry().lines.iter().map(|(_, a, b)| [r(a.x), r(a.y), r(b.x), r(b.y)]).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v
}
/// A horizontal multiline (0,0)–(10,0) and a vertical one (5,-5)–(5,5), both centred (Zero)
/// with scale 2: elements at y = ±1 and x = 4 / 6.
fn crossing(s: &mut Session) -> (Handle, Handle) {
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero", "scale": 2})).unwrap());
let b = h(&s.execute("mline", &json!({"points": [[5, -5], [5, 5]], "justification": "zero", "scale": 2})).unwrap());
(a, b)
}
#[test]
fn mline_is_one_object_with_the_standard_style() {
let mut s = Session::new();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 5]]})).unwrap());
assert_eq!(s.doc().unwrap().model.len(), 1);
let m = mline(&s, a);
assert_eq!(m.style, "STANDARD");
// Top justification: the first element runs through the points, the other one unit to the right.
assert_eq!(pieces(&m), vec![[0.0, -1.0, 11.0, -1.0], [0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [11.0, -1.0, 11.0, 5.0]]);
assert_eq!(s.doc().unwrap().model.iter().next().unwrap().kind.type_name(), "MLine");
assert!(s.execute("mline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("mline", &json!({"points": [[0, 0], [1, 0]], "style": "NOPE"})).is_err());
}
#[test]
fn mlstyle_defines_elements_caps_and_fill() {
let mut s = Session::new();
let r = s
.execute(
"mlstyle",
&json!({"name": "Wall", "elements": [{"offset": 0.5, "color": "red"}, {"offset": 0}, {"offset": -0.5, "linetype": "Hidden"}],
"fill": "yellow", "joints": true, "startCaps": {"line": true}, "endCaps": {"outerArc": true}, "current": false}),
)
.unwrap();
assert_eq!(r["current"], "STANDARD");
assert_eq!(r["styles"].as_array().unwrap().len(), 2);
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "style": "wall", "justification": "zero"})).unwrap());
let m = mline(&s, a);
assert_eq!(m.style, "Wall");
assert_eq!(m.def.elements.len(), 3);
let g = m.geometry();
assert_eq!(g.lines.len(), 6);
// Start cap line, end half circle, one joint at the corner.
assert_eq!(g.caps.len(), 3);
assert_eq!(g.fill.len(), 1);
// A style in use keeps its elements; its description may change.
assert!(s.execute("mlstyle", &json!({"name": "Wall", "elements": [{"offset": 1}, {"offset": -1}]})).is_err());
s.execute("mlstyle", &json!({"name": "Wall", "description": "outer walls", "current": false})).unwrap();
assert!(s.execute("mlstyle", &json!({"delete": "Wall"})).is_err(), "in use");
assert!(s.execute("mlstyle", &json!({"delete": "STANDARD"})).is_err());
assert!(s.execute("mlstyle", &json!({"name": "Bad", "elements": []})).is_err());
assert!(s.execute("mlstyle", &json!({"name": "Bad", "startCaps": {"angle": 5}})).is_err());
// New styles become current; MLINE then draws with them.
s.execute("mlstyle", &json!({"name": "Thin", "copyFrom": "Wall", "fill": false})).unwrap();
assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Thin");
let b = h(&s.execute("mline", &json!({"points": [[0, 20], [5, 20]]})).unwrap());
assert_eq!(mline(&s, b).def.elements.len(), 3);
assert!(mline(&s, b).def.fill.is_none());
s.execute("erase", &json!({"handles": [b.hex()]})).unwrap();
s.execute("mlstyle", &json!({"name": "Thin", "rename": "Slim"})).unwrap();
assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Slim");
s.execute("mlstyle", &json!({"name": "STANDARD"})).unwrap();
s.execute("mlstyle", &json!({"delete": "Slim"})).unwrap();
assert!(s.doc().unwrap().mline_style("Slim").is_none());
}
#[test]
fn mline_command_line_style_option() {
let mut s = Session::new();
s.execute("mlstyle", &json!({"name": "Triple", "elements": [{"offset": 1}, {"offset": 0}, {"offset": -1}], "current": false})).unwrap();
s.cmdline("mline st ?").unwrap();
assert!(s.log.iter().any(|l| l.contains("Triple")), "{:?}", s.log);
for t in ["triple", "j z 0,0 10,0", ""] {
s.cmdline(t).unwrap();
}
assert!(s.running.is_none());
let m = match &s.doc().unwrap().model.iter().last().unwrap().kind {
EntityKind::MLine(m) => m.clone(),
k => panic!("{k:?}"),
};
assert_eq!((m.style.as_str(), m.def.elements.len()), ("Triple", 3));
assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Triple");
assert!(s.cmdline("mline st nosuch").is_err() || s.log.iter().any(|l| l.contains("Cannot find")));
}
#[test]
fn mledit_crosses() {
// Closed cross: the first multiline is broken where the second crosses it.
let mut s = Session::new();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "cc", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 4.0, -1.0], [0.0, 1.0, 4.0, 1.0], [6.0, -1.0, 10.0, -1.0], [6.0, 1.0, 10.0, 1.0]]);
assert_eq!(pieces(&mline(&s, b)).len(), 2);
// Open cross: the second one's outer elements open too.
let mut s = Session::new();
let (a, b) = crossing(&mut s);
s.cmdline("mledit oc 2,1 4,3").unwrap();
s.cmdline("").unwrap();
assert_eq!(pieces(&mline(&s, a)).len(), 4);
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, -1.0], [4.0, 1.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
// Merged cross: the outer elements of both break, inner ones run through.
let mut s = Session::new();
s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "mc", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
assert_eq!(pieces(&mline(&s, a)).len(), 5);
assert_eq!(pieces(&mline(&s, b)).len(), 5);
assert!(pieces(&mline(&s, a)).contains(&[0.0, 0.0, 10.0, 0.0]), "centre line continues");
}
#[test]
fn mledit_tees_and_corner() {
// Closed tee: the first multiline ends at the second one's near side.
let mut s = Session::new();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "ct", "first": a.hex(), "p1": [1, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
let m = mline(&s, a);
assert_eq!(m.points(), vec![Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)]);
assert!(m.no_end_caps);
assert_eq!(pieces(&mline(&s, b)).len(), 2);
// Open tee: the second one opens where the first meets it.
let mut s = Session::new();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "ot", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
assert_eq!(mline(&s, a).points(), vec![Vec2::new(6.0, 0.0), Vec2::new(10.0, 0.0)]);
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
// Merged tee: the outer elements stop at the second's near side, which opens, and the inner
// ones run on to the second's inner element, which runs through.
let mut s = Session::new();
s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
assert_eq!(mline(&s, a).points(), vec![Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0)]);
assert_eq!(pieces(&mline(&s, a)), vec![[5.0, 0.0, 10.0, 0.0], [6.0, -1.0, 10.0, -1.0], [6.0, 1.0, 10.0, 1.0]]);
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [5.0, -5.0, 5.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
// Picked on the other side, from the command line.
let mut s = Session::new();
s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap();
let (a, b) = crossing(&mut s);
s.cmdline("mledit mt 1,0 5,3").unwrap();
s.cmdline("").unwrap();
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 4.0, -1.0], [0.0, 0.0, 5.0, 0.0], [0.0, 1.0, 4.0, 1.0]]);
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, -1.0], [4.0, 1.0, 4.0, 5.0], [5.0, -5.0, 5.0, 5.0], [6.0, -5.0, 6.0, 5.0]]);
// Two elements have nothing inner to merge: the same as the open tee.
let mut s = Session::new();
let (a, b) = crossing(&mut s);
s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap();
assert_eq!(mline(&s, a).points(), vec![Vec2::new(6.0, 0.0), Vec2::new(10.0, 0.0)]);
assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]);
// Corner joint: two multilines become one round the corner.
let mut s = Session::new();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let b = h(&s.execute("mline", &json!({"points": [[12, 2], [12, 10]]})).unwrap());
s.execute("mledit", &json!({"tool": "cj", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [12, 8]})).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 1);
assert_eq!(mline(&s, a).points(), vec![Vec2::new(0.0, 0.0), Vec2::new(12.0, 0.0), Vec2::new(12.0, 10.0)]);
}
#[test]
fn mledit_vertices_cuts_and_welds() {
let mut s = Session::new();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero", "scale": 2})).unwrap());
s.execute("mledit", &json!({"tool": "av", "handle": a.hex(), "point": [5, 0.3]})).unwrap();
assert_eq!(mline(&s, a).vertices.len(), 3);
s.execute("mledit", &json!({"tool": "dv", "handle": a.hex(), "point": [5.2, 0]})).unwrap();
assert_eq!(mline(&s, a).points(), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]);
assert!(s.execute("mledit", &json!({"tool": "dv", "handle": a.hex(), "point": [0, 0]})).is_err(), "two vertices stay");
// Cut single: only the element picked.
s.execute("mledit", &json!({"tool": "cs", "handle": a.hex(), "p1": [3, 1.1], "p2": [5, 1]})).unwrap();
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 10.0, -1.0], [0.0, 1.0, 3.0, 1.0], [5.0, 1.0, 10.0, 1.0]]);
// Cut all, then weld all over both breaks.
s.cmdline("mledit ca 7,1 8,1").unwrap();
s.cmdline("").unwrap();
assert_eq!(pieces(&mline(&s, a)).len(), 5);
s.execute("mledit", &json!({"tool": "wa", "handle": a.hex(), "p1": [2, 0], "p2": [9, 0]})).unwrap();
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 10.0, -1.0], [0.0, 1.0, 10.0, 1.0]]);
// Undo brings the breaks back.
s.execute("u", &json!({})).unwrap();
assert_eq!(pieces(&mline(&s, a)).len(), 5);
}
#[test]
fn transforms_grips_explode() {
let mut s = Session::new();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.execute("mledit", &json!({"tool": "ca", "handle": a.hex(), "p1": [4, 0], "p2": [6, 0]})).unwrap();
// Mirrored: the second element moves to the other side.
s.execute("mirror", &json!({"handles": [a.hex()], "p1": [0, 0], "p2": [1, 0], "erase": true})).unwrap();
let a = s.doc().unwrap().model.iter().last().unwrap().handle;
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, 0.0, 4.0, 0.0], [0.0, 1.0, 4.0, 1.0], [6.0, 0.0, 10.0, 0.0], [6.0, 1.0, 10.0, 1.0]]);
// Scaled: offsets and breaks scale.
s.execute("scale", &json!({"handles": [a.hex()], "base": [0, 0], "factor": 2})).unwrap();
assert_eq!(pieces(&mline(&s, a)), vec![[0.0, 0.0, 8.0, 0.0], [0.0, 2.0, 8.0, 2.0], [12.0, 0.0, 20.0, 0.0], [12.0, 2.0, 20.0, 2.0]]);
s.execute("move", &json!({"handles": [a.hex()], "delta": [1, 1]})).unwrap();
s.execute("rotate", &json!({"handles": [a.hex()], "base": [1, 1], "angle": 90})).unwrap();
let near = |p: &[Vec2], q: &[Vec2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| a.near(*b, 1e-9));
assert!(near(&mline(&s, a).points(), &[Vec2::new(1.0, 1.0), Vec2::new(1.0, 21.0)]));
// Grips are the vertices.
let k = s.doc().unwrap().entity(a).unwrap().kind.clone();
assert!(near(&k.grips(), &mline(&s, a).points()));
let moved = cadcraft_engine::grips::stretch_grip(&k, 1, Vec2::new(5.0, 21.0)).unwrap();
let EntityKind::MLine(m) = moved else { panic!() };
assert_eq!(m.points()[1], Vec2::new(5.0, 21.0));
// Explode: lines in the elements' colours.
let mut s = Session::new();
s.execute(
"mlstyle",
&json!({"name": "R", "elements": [{"offset": 0.5, "color": "red"}, {"offset": -0.5}], "endCaps": {"line": true, "outerArc": true}}),
)
.unwrap();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
s.execute("explode", &json!({"handles": [a.hex()]})).unwrap();
let d = s.doc().unwrap();
let lines = d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
let arcs = d.model.iter().filter(|e| matches!(e.kind, EntityKind::Arc(_))).count();
assert_eq!((lines, arcs), (3, 1));
assert!(d.model.iter().any(|e| e.common.color == cadcraft_color::Color::Index(1)));
assert!(!d.model.iter().any(|e| matches!(e.kind, EntityKind::MLine(_))));
}
#[test]
fn snaps_selection_extents_and_render() {
let mut s = Session::new();
s.execute("mlstyle", &json!({"name": "R", "elements": [{"offset": 0.5, "color": "red"}, {"offset": -0.5}], "fill": "blue"})).unwrap();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero"})).unwrap());
let d = s.doc().unwrap();
// Endpoint of the lower element, and the intersection-free midpoint of the upper one.
let hit = snap::osnap(d, &Space::Model, Vec2::new(10.05, -0.45), 0.3, mode::END, None, false).unwrap();
assert_eq!(hit.point, Vec2::new(10.0, -0.5));
let hit = snap::osnap(d, &Space::Model, Vec2::new(5.05, 0.45), 0.3, mode::MID, None, false).unwrap();
assert_eq!(hit.point, Vec2::new(5.0, 0.5));
// Picking an element picks the multiline.
assert_eq!(cadcraft_engine::select::pick(d, &Space::Model, Vec2::new(3.0, 0.52), 0.1), Some(a));
let ext = d.extents(&Space::Model);
assert!(ext.min.near(Vec2::new(0.0, -0.5), 1e-9) && ext.max.near(Vec2::new(10.0, 0.5), 1e-9), "{ext:?}");
// Drawn: the fill, and each element in its own colour.
let list = cadcraft_render::build(d, &Space::Model, &cadcraft_render::Options::default());
let red = cadcraft_color::aci_rgb(1);
let blue = cadcraft_color::aci_rgb(5);
assert!(list.prims.iter().any(|p| p.color == red && matches!(p.kind, cadcraft_render::Kind::Polyline)));
assert!(list.prims.iter().any(|p| p.color == blue && matches!(p.kind, cadcraft_render::Kind::Tris)));
}
#[test]
fn hostile_mline_params_never_panic() {
let mut s = Session::new();
let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let b = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
let bad = [
("mline", json!({"points": [[0, 0], [1e308, 1e308], [-1e308, 0]], "scale": 1e308})),
("mline", json!({"points": [[0, 0], [0, 0], [0, 0]]})),
("mline", json!({"points": "x", "closed": true})),
("mlstyle", json!({"name": "X", "elements": [{"offset": 1e308}, {"offset": "a"}]})),
("mlstyle", json!({"name": "X", "elements": (0..100).map(|i| json!({"offset": i})).collect::<Vec<_>>()})),
("mlstyle", json!({"name": "", "fill": 99999})),
("mlstyle", json!({"name": "a<b"})),
("mledit", json!({"tool": "cc", "first": a.hex(), "p1": [0, 0], "second": b.hex(), "p2": [0, 0]})),
("mledit", json!({"tool": "cj", "first": a.hex(), "p1": [0, 0], "second": b.hex(), "p2": [1e308, 0]})),
("mledit", json!({"tool": "ct", "first": a.hex(), "p1": [0, 0], "second": a.hex(), "p2": [0, 0]})),
("mledit", json!({"tool": "cs", "handle": a.hex(), "p1": [1e308, 0], "p2": [-1e308, 0]})),
("mledit", json!({"tool": "av", "handle": "zz", "point": [0, 0]})),
("mledit", json!({"tool": "nope"})),
];
for (id, p) in bad {
let _ = s.execute(id, &p);
}
for t in ["mledit", "cc", "0,0", "0,0", "", "mline", "s", "-1e308", "0,0", "1,1", ""] {
let _ = s.cmdline(t);
}
// Everything left draws.
let d = s.doc().unwrap();
let _ = cadcraft_render::build(d, &Space::Model, &cadcraft_render::Options::default());
}