Files
cadcraft/crates/engine/tests/mline.rs
T
2026-10-11 11:48:27 -04:00

287 lines
15 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]]);
assert!(s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).is_err());
// 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());
}